From 99d9f7a8f161fc6262d89fcb69dcb25e9f5fa55e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 27 Aug 2026 08:36:42 -0700 Subject: [PATCH 1/2] fix(ui): 0.32.0 adoption feedback: isolate the default KaTeX loader, ship HANDOFF.md, document three contracts - utils/math: the default import('katex') moves to utils/math-default-loader and is called only while no host loader is registered; a registered loader is never backfilled by it. Its own module lets a host alias the chunk away. - README/HANDOFF: onUnanchoredChange delivers nothing before the bridge's first post-restore report per generation; projectHostThreads is HTML-only; a host's cap-dropped handling is a backstop once maxAdditionalTargets is enforced upstream. - package.json: HANDOFF.md joins the tarball so README's references resolve. AI-assisted (Claude) under maintainer direction. --- packages/ui/HANDOFF.md | 19 ++++++++++++---- packages/ui/README.md | 8 +++---- packages/ui/package.json | 1 + packages/ui/utils/math-default-loader.ts | 24 ++++++++++++++++++++ packages/ui/utils/math.test.ts | 21 +++++++++++++++++ packages/ui/utils/math.ts | 29 +++++++++++++----------- tests/entry-assets.test.ts | 12 ++++++++-- 7 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 packages/ui/utils/math-default-loader.ts diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 56fade0c8..360167d17 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -92,7 +92,7 @@ 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 | +| `mathRendererLoader` | `() => Promise` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load | `utils/math-default-loader`'s `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 @@ -469,6 +469,17 @@ Four modules that used to ride every document read for a host that bundles by ro 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. + **Where the default `import('katex')` lives, and how to drop its chunk (post-0.32.0 adoption fix).** The default loader is `utils/math-default-loader` (`loadDefaultMathRenderer`), the package's only runtime mention of `katex` outside `math-eager`; `utils/math` calls it only while no loader is registered (`loader === null`), and a registered loader is never backfilled by it, not even after the host's load rejects (pinned in `utils/math.test.ts`). So with a loader registered the default is never *requested*. It is still *emitted*: Rollup decides chunks statically and cannot see a runtime registration, so a host build that registers a loader still carries a `katex-*.js` chunk with an `import()` site pointing at it from the package. Measured on a two-entry Vite 6 consumer of this checkout (one entry registering a loader that is not KaTeX, one registering nothing): both builds emit one 484 KB chunk carrying the KaTeX body. A host that wants that chunk gone aliases the default module at a stub, which is why it is its own module: + + ```ts + // vite.config.ts of a host that registers mathRendererLoader + resolve: { alias: [{ find: /^(\.\/|@plannotator\/ui\/utils\/)math-default-loader$/, replacement: '/src/no-default-math.ts' }] } + // src/no-default-math.ts + export function loadDefaultMathRenderer(): Promise { return Promise.reject(new Error('default math loader aliased out')); } + ``` + + With the alias the same consumer build emits zero chunks carrying the KaTeX body and the entry's only `import()` in that area is the host's own loader chunk. Do not alias without registering a loader: math would then render as TeX text forever. Plannotator's entries import `math-eager`, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default through `inlineDynamicImports` as before (`tests/entry-assets.test.ts` pins the split: `utils/math` has no `import('katex')` site, `utils/math-default-loader` has the only one). + 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. @@ -551,9 +562,9 @@ Pinned by "unanchored ids are reported on change" in `components/html-viewer/src Nine additive seams so a host can run the raw-HTML annotation surface with the same experience Plannotator ships, without app-local code around `HtmlViewer`. Every default reproduces 0.31.0 behavior; Plannotator's own app passes the same defaults and renders the same DOM (proven by a real-browser A/B of the header, the overlay markers and the annotations panel on a main build versus this build). -1. **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes = 16384, maxTargets = 16 })`** are exported from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`). The first projects a host's stored rows (`{ id, originalText, htmlAnchor?, htmlAdditionalTargets?, state?, text?, author?, createdA?, images? }`) onto the `annotations` prop **in the host's order, which is the marker numbering**; an element anchor without quoted text stays a page `COMMENT`, anchors validate fail-closed, and `maxTargets` caps additional targets on read (default: the viewer's 16). A row with nothing restorable (no quote, no element anchor) projects by `documentLevel`: **`'global'` (the default, Plannotator's model)** makes it a `GLOBAL_COMMENT`, a document-level comment the panel renders without a quote line and the unanchored report never names; **`'unanchored'`** keeps it a page `COMMENT` with an empty quote and no anchor, which the unanchored report names (the panel shows an empty quote line), for hosts that treat such rows as comments that lost their place. The second trims a composed comment's anchor for persistence: product cap first, then a byte budget that truncates the quote down to its 400-char floor before shedding targets from the end, with `droppedTargets` (the total), `capDroppedTargets` and `sizeDroppedTargets` reported (a size drop must never be announced as the product cap). Kept targets serialize with keys in `text, label, anchor` order, the reference host's wire order, so stored anchors and fingerprints over them are stable on adoption. An input already in that order and within every bound round-trips byte-identical. +1. **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes = 16384, maxTargets = 16 })`** are exported from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`). The first projects a host's stored rows (`{ id, originalText, htmlAnchor?, htmlAdditionalTargets?, state?, text?, author?, createdA?, images? }`) onto the `annotations` prop **in the host's order, which is the marker numbering**; an element anchor without quoted text stays a page `COMMENT`, anchors validate fail-closed, and `maxTargets` caps additional targets on read (default: the viewer's 16). A row with nothing restorable (no quote, no element anchor) projects by `documentLevel`: **`'global'` (the default, Plannotator's model)** makes it a `GLOBAL_COMMENT`, a document-level comment the panel renders without a quote line and the unanchored report never names; **`'unanchored'`** keeps it a page `COMMENT` with an empty quote and no anchor, which the unanchored report names (the panel shows an empty quote line), for hosts that treat such rows as comments that lost their place. The second trims a composed comment's anchor for persistence: product cap first, then a byte budget that truncates the quote down to its 400-char floor before shedding targets from the end, with `droppedTargets` (the total), `capDroppedTargets` and `sizeDroppedTargets` reported (a size drop must never be announced as the product cap). Kept targets serialize with keys in `text, label, anchor` order, the reference host's wire order, so stored anchors and fingerprints over them are stable on adoption. An input already in that order and within every bound round-trips byte-identical. **`projectHostThreads` is HTML-only.** The projection carries exactly what the raw-HTML surface reads (`originalText`, `htmlAnchor`, `htmlAdditionalTargets`, the type, the presentational fields) and pins `blockId` to `""`, `startOffset` / `endOffset` to `0`, with no `startMeta` / `endMeta`. The markdown `Viewer` restores by `blockId` plus text search inside that block (`hooks/useAnnotationHighlighter`), so a markdown thread projected through it would never re-anchor; markdown threads still need the host's own projection that carries `blockId`, the offsets and the web-highlighter metas. A markdown-aware projection is not a metas passthrough (the block id and offsets are the anchor) and is deliberately not attempted here. -2. **`onUnanchoredChange` is complete over the `annotations` prop and keyed to the bridge's restore.** On every bridge `ready` (a fresh document, a srcdoc reload) the viewer posts its restore batch and then asks the bridge for one complete report (`report-unanchored`); the bridge answers after its next complete overlay pass **even when the set is unchanged, the empty set included**, and that answer is the first delivery for that document. Nothing is delivered before it. Later bridge reports deliver as they arrive; a prop-side change delivers only when the union actually changes. The union adds what the bridge cannot see: page rows with no quoted text and no element anchor are reported without being posted (a `GLOBAL_COMMENT` is not, by design), and an id the viewer minted for a locally created comment that the host swapped out of `annotations` for its own id is dropped. What this replaces on the host side: the `mark-applied` bookkeeping that fed an unanchored set (failed verdicts, textless rows, the swapped-out local id). It does not replace `mark-applied` for the local-to-server mark swap itself: the package still does not parse that message, and a host that wants the no-flash swap keeps removing its local mark with `removeHighlight` on its own refetch (a host content with one frame of no mark removes it on the prop change instead). +2. **`onUnanchoredChange` is complete over the `annotations` prop and keyed to the bridge's restore.** On every bridge `ready` (a fresh document, a srcdoc reload) the viewer posts its restore batch and then asks the bridge for one complete report (`report-unanchored`); the bridge answers after its next complete overlay pass **even when the set is unchanged, the empty set included**, and that answer is the first delivery for that document. Nothing is delivered before it, per document and per reload generation: a prop-side change that lands before the bridge's first post-restore report is folded into that report, not delivered on its own, so a host must not wait on a prop-side set arriving before the restore (a "no callback yet" state until then is the contract, not a missed event). Later bridge reports deliver as they arrive; a prop-side change delivers only when the union actually changes. The union adds what the bridge cannot see: page rows with no quoted text and no element anchor are reported without being posted (a `GLOBAL_COMMENT` is not, by design), and an id the viewer minted for a locally created comment that the host swapped out of `annotations` for its own id is dropped. What this replaces on the host side: the `mark-applied` bookkeeping that fed an unanchored set (failed verdicts, textless rows, the swapped-out local id). It does not replace `mark-applied` for the local-to-server mark swap itself: the package still does not parse that message, and a host that wants the no-flash swap keeps removing its local mark with `removeHighlight` on its own refetch (a host content with one frame of no mark removes it on the prop change instead). 3. **`hooks/useHtmlRefresh({ enabled?, documentKey?, fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`** returns `{ canRefresh, isRefreshing, reloadGeneration, refresh, reportAnnotationRestore }`. `fetchSnapshot(documentKey)` resolves `{ status: 'ok', rawHtml } | { status: 'missing' } | { status: 'unavailable' }`; a rejection counts as `unavailable`. Key the viewer on `reloadGeneration` and wire its `onUnanchoredChange` to `reportAnnotationRestore`. The hook owns the guards: a fetch superseded by a newer refresh or by a `documentKey` change never applies, and the restore acknowledgement fires once per reload generation with the viewer's first report for the remounted document, which by item 2 is the bridge's post-restore set, the empty set included, so a host clears its chip when a previous orphan re-anchors. Notifications are the host's, through `onResult`. @@ -563,7 +574,7 @@ Nine additive seams so a host can run the raw-HTML annotation surface with the s 6. **`HtmlViewer` `scrollBehavior?: 'smooth' | 'auto'`** rides `scroll-to { id, behavior? }` so a host can carry its `prefers-reduced-motion` across the iframe boundary. Absent means smooth, as before; anything else fails closed to smooth. -7. **`HtmlViewer` `maxAdditionalTargets?: number`** (0..16, default 16) is the host's product cap on shift-click targets per comment: enforced at the parent trust boundary, on submit and on restore, and carried on `arm-multi-select { key, max }` so the bridge's toggle stops at the same number for that draft (reset with the arm on every draft; a value above 16 never raises the package cap). Absent leaves the arm message unchanged. A host that adopts the package's 16 needs neither this prop nor a message-counting listener. +7. **`HtmlViewer` `maxAdditionalTargets?: number`** (0..16, default 16) is the host's product cap on shift-click targets per comment: enforced at the parent trust boundary, on submit and on restore, and carried on `arm-multi-select { key, max }` so the bridge's toggle stops at the same number for that draft (reset with the arm on every draft; a value above 16 never raises the package cap). Absent leaves the arm message unchanged. A host that adopts the package's 16 needs neither this prop nor a message-counting listener. Consequence for a host that passes a smaller cap: because it is enforced upstream at every step (the bridge stops the toggle, the parent boundary trims on submit and on restore, `projectHostThreads` `maxTargets` trims on read), a composed comment never reaches host code with more targets than the cap, so the host's own cap-dropped handling (`capDroppedTargets` from `buildPersistedHtmlAnchor`, or a counting listener) is unreachable in normal operation. Keep it only as a backstop for rows written by an older host build or another writer; the byte-budget drop (`sizeDroppedTargets`) is a different path and remains reachable. 8. **`ExternalAnnotationTransport.subscribe` may emit `snapshot` from a host push.** `useExternalAnnotations` falls back to 500 ms version-gated polling only when the stream errors before its first event. A transport whose `subscribe` delivers a `{ type: 'snapshot', annotations, version }` event whenever the host's realtime layer signals a change (a Durable Object poke, a socket message) keeps the hook on the push path and the fallback poll is never entered. No package change; this is the sanctioned shape. diff --git a/packages/ui/README.md b/packages/ui/README.md index 6d8f4f768..a113df2d3 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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). +- **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`, called only while no loader is registered; a registered loader is never backfilled by it. 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. - **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`. @@ -102,12 +102,12 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^ Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui: -- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. -- **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. +- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; markdown threads (the `Viewer` surface, which restores by `blockId` plus text search) still need the host's own projection. +- **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict. - **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`. - **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides. - **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards. -- **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too). +- **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too). With the cap enforced upstream (bridge toggle, parent trust boundary on submit and on restore, `projectHostThreads` `maxTargets` on read), a composed comment never reaches the host with more targets than the cap, so a host's own cap-dropped handling (`capDroppedTargets` from `buildPersistedHtmlAnchor`, or a message-counting listener) is unreachable in normal operation; keep it only as a backstop for rows written by an older host build or by another writer. Byte-budget drops (`sizeDroppedTargets`) are a separate path and remain reachable. - An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll. See HANDOFF.md ยง "HTML annotation parity seams". diff --git a/packages/ui/package.json b/packages/ui/package.json index 20e622b4a..d829abb06 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,6 +46,7 @@ "print.css", "styles.css", "plannotator.webp", + "HANDOFF.md", "!**/*.test.ts", "!**/*.test.tsx", "!test-setup" diff --git a/packages/ui/utils/math-default-loader.ts b/packages/ui/utils/math-default-loader.ts new file mode 100644 index 000000000..e416ea4be --- /dev/null +++ b/packages/ui/utils/math-default-loader.ts @@ -0,0 +1,24 @@ +/** + * The default math renderer loader: KaTeX's JS only, fetched lazily. + * + * This module is the ONLY place in `@plannotator/ui` that names `katex` at + * runtime (`./math-eager` names it too, but a host chooses to import that). + * `./math` calls `loadDefaultMathRenderer` only when no host loader is + * registered (`setMathRendererLoader` / `configurePlannotatorUI({ + * mathRendererLoader })`), so a host that registers one never runs the + * `import('katex')` below and never requests the chunk it produces. + * + * Keeping the import in its own module is what lets a bundler drop the chunk + * entirely: chunk emission is static, so a host that registers a loader and + * wants no KaTeX chunk from the package at all points this module at a stub + * (see HANDOFF.md "Lazy renderers and eager entries", the alias recipe). The + * stylesheet is deliberately NOT imported here; CSS loading stays the host's + * job (HANDOFF.md "Math rendering"), and a host that already serves + * `katex.min.css` would otherwise load it twice. + */ + +import type { MathRenderer } from './math'; + +export function loadDefaultMathRenderer(): Promise { + return import('katex').then((m) => m.default); +} diff --git a/packages/ui/utils/math.test.ts b/packages/ui/utils/math.test.ts index f8624c765..485ae2d02 100644 --- a/packages/ui/utils/math.test.ts +++ b/packages/ui/utils/math.test.ts @@ -152,6 +152,27 @@ describe('loadMathRenderer', () => { expect(attempts).toBe(2); }); + test('with no loader registered, the default path loads KaTeX', async () => { + // Guards the module split: the default lives in ./math-default-loader and + // is wired back in only through the null-loader branch. + expect(await loadMathRenderer()).toBe(katex); + expect(getMathRendererSource()).toBe('loader'); + }); + + test('a registered loader is never backfilled by the default, even after it rejects', async () => { + // The observable that would regress if the default `import('katex')` + // became reachable while a host loader is registered: a host whose chunk + // failed would see KaTeX silently appear in the slot (and, in a chunking + // build, an unrequested chunk request). The slot must stay empty. + setMathRendererLoader(async () => { + throw new Error('host chunk failed'); + }); + await expect(loadMathRenderer()).rejects.toThrow('host chunk failed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(getMathRenderer()).toBeNull(); + expect(getMathRendererSource()).toBeNull(); + }); + test('honors the host loader seam and registers what it returns', async () => { const hostRenderer: MathRenderer = { renderToString: (tex) => `${tex}`, diff --git a/packages/ui/utils/math.ts b/packages/ui/utils/math.ts index 20ab6f8e6..d3491a9ce 100644 --- a/packages/ui/utils/math.ts +++ b/packages/ui/utils/math.ts @@ -11,12 +11,16 @@ * 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). + * the dependency is named is `./math-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). That default is called only while no host loader is registered, and + * it lives in its own module so a host that registers a loader can alias it + * away and drop the chunk (see HANDOFF.md "Lazy renderers and eager entries"). */ import type { KatexOptions } from 'katex'; +import { loadDefaultMathRenderer } from './math-default-loader'; /** The subset of KaTeX's API the renderer needs. `katex` itself satisfies it. */ export interface MathRenderer { @@ -25,13 +29,6 @@ export interface MathRenderer { 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 @@ -43,7 +40,13 @@ export type MathRendererSource = 'plannotator-math-eager' | 'loader' | 'host'; let renderer: MathRenderer | null = null; let rendererSource: MathRendererSource | null = null; -let loader: MathRendererLoader = defaultMathRendererLoader; +/** + * The host loader, or `null` while none is registered. `null` is the only + * state in which `loadMathRenderer()` reaches `loadDefaultMathRenderer` and + * its `import('katex')`; a registered loader is never backfilled by the + * default, not even after it rejects. + */ +let loader: MathRendererLoader | null = null; let pending: Promise | null = null; const listeners = new Set<() => void>(); @@ -97,7 +100,7 @@ export function setMathRendererLoader(next: MathRendererLoader): void { export function loadMathRenderer(): Promise { if (renderer) return Promise.resolve(renderer); if (!pending) { - const attempt = loader().then( + const attempt = (loader ? loader() : loadDefaultMathRenderer()).then( (loaded) => { setMathRenderer(loaded, 'loader'); return loaded; @@ -116,7 +119,7 @@ export function loadMathRenderer(): Promise { export function resetMathRenderer(): void { renderer = null; rendererSource = null; - loader = defaultMathRendererLoader; + loader = null; pending = null; notify(); } diff --git a/tests/entry-assets.test.ts b/tests/entry-assets.test.ts index b48750d68..be4627e10 100644 --- a/tests/entry-assets.test.ts +++ b/tests/entry-assets.test.ts @@ -96,7 +96,15 @@ describe('review entry assets', () => { 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')"); + // The default `import('katex')` lives in its own module so a host that + // registers a loader can alias it away; math.ts must not grow a second + // site, or the alias stops dropping the chunk. + // (Comments name the call in prose, so this is matched as code: a call + // that starts a line or an expression, never a backtick-quoted mention.) + expect(read('packages/ui/utils/math.ts')).not.toMatch(/[^`'"]import\('katex'\)/); + expect(read('packages/ui/utils/math.ts')).toContain("from './math-default-loader'"); + expect(read('packages/ui/utils/math-default-loader.ts')).not.toMatch(staticImport('katex')); + expect(read('packages/ui/utils/math-default-loader.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')"); @@ -124,7 +132,7 @@ describe('review entry assets', () => { // - 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, + // through utils/math-default-loader.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 From 54de6d675a0f294e8dec95e94c64fe9b1666c4e7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 27 Aug 2026 08:42:59 -0700 Subject: [PATCH 2/2] docs(ui): correct the projectHostThreads markdown rationale, note in-flight default load, widen katex site regex - README/HANDOFF: a projected COMMENT with quoted text still re-anchors on the markdown Viewer by whole-document text search; what blockId "" and offsets 0 actually lose is export ordering, the lines N-M label, repeated text disambiguation and the no-flash meta restore. - Loader docs: a default KaTeX load already in flight at registration still fills the slot (pre-existing), so register before the first math render. - entry-assets: match either quote style for the import('katex') site. AI-assisted (Claude) under maintainer direction. --- packages/ui/HANDOFF.md | 6 +++--- packages/ui/README.md | 4 ++-- tests/entry-assets.test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 360167d17..b273e72f9 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -92,7 +92,7 @@ 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"). Once registered, the package default is never called, not even as a fallback after a rejected load | `utils/math-default-loader`'s `import('katex')`, JS only; CSS stays yours | +| `mathRendererLoader` | `() => Promise` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load; a default load already in flight at registration still fills the slot (pre-existing, see `setMathRendererLoader`), so register before the first math render | `utils/math-default-loader`'s `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 @@ -469,7 +469,7 @@ Four modules that used to ride every document read for a host that bundles by ro 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. - **Where the default `import('katex')` lives, and how to drop its chunk (post-0.32.0 adoption fix).** The default loader is `utils/math-default-loader` (`loadDefaultMathRenderer`), the package's only runtime mention of `katex` outside `math-eager`; `utils/math` calls it only while no loader is registered (`loader === null`), and a registered loader is never backfilled by it, not even after the host's load rejects (pinned in `utils/math.test.ts`). So with a loader registered the default is never *requested*. It is still *emitted*: Rollup decides chunks statically and cannot see a runtime registration, so a host build that registers a loader still carries a `katex-*.js` chunk with an `import()` site pointing at it from the package. Measured on a two-entry Vite 6 consumer of this checkout (one entry registering a loader that is not KaTeX, one registering nothing): both builds emit one 484 KB chunk carrying the KaTeX body. A host that wants that chunk gone aliases the default module at a stub, which is why it is its own module: + **Where the default `import('katex')` lives, and how to drop its chunk (post-0.32.0 adoption fix).** The default loader is `utils/math-default-loader` (`loadDefaultMathRenderer`), the package's only runtime mention of `katex` outside `math-eager`; `utils/math` calls it only while no loader is registered (`loader === null`), and a registered loader is never backfilled by it, not even after the host's load rejects (pinned in `utils/math.test.ts`). So with a loader registered the default is never *requested*. One pre-existing ordering rule still applies: a default load already in flight when the host registers its loader keeps going and fills the slot when it lands (documented on `setMathRendererLoader`), so register the loader before the first math node renders, in your entry, not in an effect. It is still *emitted*: Rollup decides chunks statically and cannot see a runtime registration, so a host build that registers a loader still carries a `katex-*.js` chunk with an `import()` site pointing at it from the package. Measured on a two-entry Vite 6 consumer of this checkout (one entry registering a loader that is not KaTeX, one registering nothing): both builds emit one 484 KB chunk carrying the KaTeX body. A host that wants that chunk gone aliases the default module at a stub, which is why it is its own module: ```ts // vite.config.ts of a host that registers mathRendererLoader @@ -562,7 +562,7 @@ Pinned by "unanchored ids are reported on change" in `components/html-viewer/src Nine additive seams so a host can run the raw-HTML annotation surface with the same experience Plannotator ships, without app-local code around `HtmlViewer`. Every default reproduces 0.31.0 behavior; Plannotator's own app passes the same defaults and renders the same DOM (proven by a real-browser A/B of the header, the overlay markers and the annotations panel on a main build versus this build). -1. **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes = 16384, maxTargets = 16 })`** are exported from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`). The first projects a host's stored rows (`{ id, originalText, htmlAnchor?, htmlAdditionalTargets?, state?, text?, author?, createdA?, images? }`) onto the `annotations` prop **in the host's order, which is the marker numbering**; an element anchor without quoted text stays a page `COMMENT`, anchors validate fail-closed, and `maxTargets` caps additional targets on read (default: the viewer's 16). A row with nothing restorable (no quote, no element anchor) projects by `documentLevel`: **`'global'` (the default, Plannotator's model)** makes it a `GLOBAL_COMMENT`, a document-level comment the panel renders without a quote line and the unanchored report never names; **`'unanchored'`** keeps it a page `COMMENT` with an empty quote and no anchor, which the unanchored report names (the panel shows an empty quote line), for hosts that treat such rows as comments that lost their place. The second trims a composed comment's anchor for persistence: product cap first, then a byte budget that truncates the quote down to its 400-char floor before shedding targets from the end, with `droppedTargets` (the total), `capDroppedTargets` and `sizeDroppedTargets` reported (a size drop must never be announced as the product cap). Kept targets serialize with keys in `text, label, anchor` order, the reference host's wire order, so stored anchors and fingerprints over them are stable on adoption. An input already in that order and within every bound round-trips byte-identical. **`projectHostThreads` is HTML-only.** The projection carries exactly what the raw-HTML surface reads (`originalText`, `htmlAnchor`, `htmlAdditionalTargets`, the type, the presentational fields) and pins `blockId` to `""`, `startOffset` / `endOffset` to `0`, with no `startMeta` / `endMeta`. The markdown `Viewer` restores by `blockId` plus text search inside that block (`hooks/useAnnotationHighlighter`), so a markdown thread projected through it would never re-anchor; markdown threads still need the host's own projection that carries `blockId`, the offsets and the web-highlighter metas. A markdown-aware projection is not a metas passthrough (the block id and offsets are the anchor) and is deliberately not attempted here. +1. **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes = 16384, maxTargets = 16 })`** are exported from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`). The first projects a host's stored rows (`{ id, originalText, htmlAnchor?, htmlAdditionalTargets?, state?, text?, author?, createdA?, images? }`) onto the `annotations` prop **in the host's order, which is the marker numbering**; an element anchor without quoted text stays a page `COMMENT`, anchors validate fail-closed, and `maxTargets` caps additional targets on read (default: the viewer's 16). A row with nothing restorable (no quote, no element anchor) projects by `documentLevel`: **`'global'` (the default, Plannotator's model)** makes it a `GLOBAL_COMMENT`, a document-level comment the panel renders without a quote line and the unanchored report never names; **`'unanchored'`** keeps it a page `COMMENT` with an empty quote and no anchor, which the unanchored report names (the panel shows an empty quote line), for hosts that treat such rows as comments that lost their place. The second trims a composed comment's anchor for persistence: product cap first, then a byte budget that truncates the quote down to its 400-char floor before shedding targets from the end, with `droppedTargets` (the total), `capDroppedTargets` and `sizeDroppedTargets` reported (a size drop must never be announced as the product cap). Kept targets serialize with keys in `text, label, anchor` order, the reference host's wire order, so stored anchors and fingerprints over them are stable on adoption. An input already in that order and within every bound round-trips byte-identical. **`projectHostThreads` is HTML-only.** The projection carries exactly what the raw-HTML surface reads (`originalText`, `htmlAnchor`, `htmlAdditionalTargets`, the type, the presentational fields) and pins `blockId` to `""`, `startOffset` / `endOffset` to `0`, with no `startMeta` / `endMeta`. On the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors: `hooks/useAnnotationHighlighter` requires `blockId` only on the math path and for a metas restore, and with no metas it falls to `findTextInDOM(originalText)`, a whole-container text search never scoped by block. What such a row loses with `blockId` `""` and offsets `0`: export ordering (`exportAnnotations` sorts by block index, which is `-1` for every such row, so they all sort first and tie), the "lines N-M" location label (`null` without a block), disambiguation when the same text appears more than once (first match wins), and the no-flash meta restore. A host that needs any of those carries `blockId`, the offsets and the web-highlighter metas in its own projection; a markdown-aware projection is more than a metas passthrough (the block id and offsets are the anchor) and is deliberately not attempted here. 2. **`onUnanchoredChange` is complete over the `annotations` prop and keyed to the bridge's restore.** On every bridge `ready` (a fresh document, a srcdoc reload) the viewer posts its restore batch and then asks the bridge for one complete report (`report-unanchored`); the bridge answers after its next complete overlay pass **even when the set is unchanged, the empty set included**, and that answer is the first delivery for that document. Nothing is delivered before it, per document and per reload generation: a prop-side change that lands before the bridge's first post-restore report is folded into that report, not delivered on its own, so a host must not wait on a prop-side set arriving before the restore (a "no callback yet" state until then is the contract, not a missed event). Later bridge reports deliver as they arrive; a prop-side change delivers only when the union actually changes. The union adds what the bridge cannot see: page rows with no quoted text and no element anchor are reported without being posted (a `GLOBAL_COMMENT` is not, by design), and an id the viewer minted for a locally created comment that the host swapped out of `annotations` for its own id is dropped. What this replaces on the host side: the `mark-applied` bookkeeping that fed an unanchored set (failed verdicts, textless rows, the swapped-out local id). It does not replace `mark-applied` for the local-to-server mark swap itself: the package still does not parse that message, and a host that wants the no-flash swap keeps removing its local mark with `removeHighlight` on its own refetch (a host content with one frame of no mark removes it on the prop change instead). diff --git a/packages/ui/README.md b/packages/ui/README.md index a113df2d3..806c6cca9 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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`, called only while no loader is registered; a registered loader is never backfilled by it. 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`, 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. - **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`. @@ -102,7 +102,7 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^ Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui: -- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; markdown threads (the `Viewer` surface, which restores by `blockId` plus text search) still need the host's own projection. +- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection. - **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict. - **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`. - **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides. diff --git a/tests/entry-assets.test.ts b/tests/entry-assets.test.ts index be4627e10..f64dd43d1 100644 --- a/tests/entry-assets.test.ts +++ b/tests/entry-assets.test.ts @@ -101,7 +101,7 @@ describe('review entry assets', () => { // site, or the alias stops dropping the chunk. // (Comments name the call in prose, so this is matched as code: a call // that starts a line or an expression, never a backtick-quoted mention.) - expect(read('packages/ui/utils/math.ts')).not.toMatch(/[^`'"]import\('katex'\)/); + expect(read('packages/ui/utils/math.ts')).not.toMatch(/[^`'"]import\(['"]katex['"]\)/); expect(read('packages/ui/utils/math.ts')).toContain("from './math-default-loader'"); expect(read('packages/ui/utils/math-default-loader.ts')).not.toMatch(staticImport('katex')); expect(read('packages/ui/utils/math-default-loader.ts')).toContain("import('katex')");