Skip to content

Performance: Fix bottleneck when a Tiptap editor appears on a document#22995

Merged
iOvergaard merged 5 commits into
release/17.5.0from
v17/improvement/parallel-tiptap-extension-loading
May 27, 2026
Merged

Performance: Fix bottleneck when a Tiptap editor appears on a document#22995
iOvergaard merged 5 commits into
release/17.5.0from
v17/improvement/parallel-tiptap-extension-loading

Conversation

@iOvergaard

@iOvergaard iOvergaard commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Three changes, applied to release/17.5.0:

  1. Parallelise extension API loading in umb-input-tiptap. Replace the for…of/await loop in #loadExtensions with Promise.all over .map, so all enabled Tiptap extension APIs are fetched in parallel. Configured-extension order in _extensions is preserved.
  2. Consolidate first-party Tiptap manifest references into a single lazy chunk. Every api: () => import('./X.tiptap-api.js') and the equivalent element: () => import(...) references — across extension APIs, toolbar/menu/action-button kinds, statusbar elements, modal elements (anchor / character-map / table), table menu-item actions, the style-menu kind, the default toolbar API fallback, and the block clipboard translators — are routed through one shared bundle file extensions/extension-apis.bundle.ts. Each manifest holds a dynamic-import thunk pointing at this single bundle, so Rollup emits one shared chunk for all first-party extension code rather than ~70 per-extension chunks, while keeping that chunk lazy — it's only fetched the first time <umb-input-tiptap> actually mounts.
  3. Restore lazy loading for the property-editor UIs and the RTE entry element. property-editors/tiptap-rte, property-editors/extensions-configuration, property-editors/toolbar-configuration and property-editors/statusbar-configuration go back to element: () => import('./X.element.js') so each loads on demand from its own chunk when a data-type settings panel (or an RTE workspace) opens, rather than being baked into the boot manifest bundle.

External (plugin-supplied) Tiptap extensions and input-tiptap.element / property-editor-ui-tiptap.element are intentionally not routed through the shared bundle: <umb-input-tiptap> is a public element usable standalone (custom dashboards, workspace views) and plugin authors should be free to ship their own per-extension chunks if they prefer. Both remain importable as their own modules; loadManifestApi accepts both the per-file () => import(...) form and the shared-bundle form.

Why

On Umbraco Cloud, opening a document workspace with a rich text editor took ~16 s uncached, with a long serial waterfall of Tiptap extension APIs being fetched one after the other from a for…of await loop — each fetch ~170 ms RTT-stacked. Replacing the loop with Promise.all collapses that to roughly one round-trip; routing the manifest references through a shared lazy bundle removes the per-extension chunk explosion that made the waterfall so long in the first place.

The Tiptap toolbar APIs (~20 of them) already loaded in a sub-100 ms parallel burst against the same server, confirming HTTP/2 multiplexing handles bulk parallel requests fine — the serial behaviour was purely application-level.

Why route through a shared bundle rather than inlining as direct class references?

An earlier iteration of this PR inlined every manifest's api/element field as a direct class reference (e.g. api: UmbTiptapBoldExtensionApi). That collapsed 70 chunks to 3 and produced the headline 17→8 s win, but it had a real downside flagged in code review (lke / mra): the implementation bytes ended up inside the manifest registration bundle, so every workspace — including ones without an RTE — paid ~700 KB of Tiptap code on boot.

The shared-bundle indirection keeps the chunk-coalescing win while restoring the lazy boundary. The data-type configuration UIs (extensions-configuration, toolbar-configuration, statusbar-configuration) consume the registry via umbExtensionsRegistry.byType(...) and read manifest metadata only — alias, label, icon, group, kind, forExtensions — so they continue to work without ever loading the Tiptap implementation code. They never call loadManifestApi / loadManifestElement.

Measured result

Same Cloud test site (17.5-rc on US East, measured from Europe with ~150 ms RTT, uncached reload):

Before After (inline) After (shared lazy bundle)
Network-tab Finish ~17–20 s ~8 s ~8 s (RTE workspace)
Tiptap JS chunks 71 3 ~3 entry + 1 lazy bundle
Boot-time Tiptap bytes 766 KB ~830 KB ~48 KB (manifests.js only)
Total requests 751 683 comparable

Network-tab Finish more than halved — the editor becomes interactive at ~8 s instead of ~17–20 s — and workspaces without an RTE no longer pay the Tiptap byte budget on boot.

After npm run build:for:cms, the relevant chunk topology in dist-cms/packages/tiptap/:

File Size Loaded when
manifests.js 48 KB Eager at boot — registers every Tiptap manifest with metadata + thunks
extension-apis.bundle-*.js 84 KB Lazy — fetched on first <umb-input-tiptap> mount
tiptap-toolbar-element-api-base-*.js 654 KB Lazy dependency of the bundle
input-tiptap.element-*.js 18 KB Lazy — when an RTE property renders
property-editor-ui-tiptap.element-*.js 2 KB Lazy — when an RTE property mounts
property-editor-ui-tiptap-*-configuration.element-*.js 5–21 KB Lazy — when a data-type settings panel opens

The remaining ~600 ms gaps between the lazy chunks would close further once <link rel="modulepreload"> is wired up (see PR #22952 / AB#68479).

Convention update

src/Umbraco.Web.UI.Client/src/packages/tiptap/CLAUDE.md is updated to document the shared-bundle pattern and the rationale: first-party manifests use () => import('../extension-apis.bundle.js').then((m) => ({ default: m.X })) thunks so they're slim at boot but consolidated into a single lazy chunk at runtime. External (plugin) Tiptap extensions may still use the per-file () => import('./my-extension.api.js') form when they want their API code in a separately fetched chunk — loadManifestApi accepts both forms.

Standalone <umb-input-tiptap> use

<umb-input-tiptap> is a public element intended for use in custom dashboards, workspace views, and external packages — not only inside <umb-property-editor-ui-tiptap>. Added:

  • components/input-tiptap/input-tiptap.test.ts — mounts <umb-input-tiptap> standalone (no property-editor wrapper) and verifies the manifest registry contains every first-party Tiptap manifest after registration.
  • components/input-tiptap/input-tiptap.stories.ts — two stories (EssentialsOnly, WithFormattingToolbar) for visual confirmation in Storybook that standalone mount triggers the lazy bundle and instantiates the editor end-to-end.

Test plan

  • eslint src/packages/tiptap — 0 errors
  • npm run check:circular — no new cycles
  • npm run compile — clean
  • vite build --mode staging — ✓ built; no new dynamic-then-static warnings for tiptap files
  • npm run build:for:cms — full backoffice build succeeds; tiptap chunk count drops from 71 to ~14 entry points in dist, with the implementation code in a single ~84 KB lazy bundle that depends on a 654 KB sibling chunk
  • web-test-runner src/packages/tiptap/**/*.test.ts — 3 tests, all passing (1 existing + 2 new standalone tests)
  • Manual on Cloud (inline version): built locally, overlaid packages/tiptap/ via Kudu, restarted the site, captured a fresh HAR — Network Finish dropped from ~17–20 s to ~8 s on the same document
  • Manual on Cloud (shared lazy bundle version): re-test with the latest commit to verify non-RTE workspaces no longer pay the Tiptap byte budget on boot
  • CI E2E

Risk

  • Constructor purity: only block.tiptap-api.ts and media-upload.tiptap-api.ts have explicit constructors. Both call super(host) plus a single consumeContext subscription — no shared-state mutation, no ordering dependency. Verified before flipping to parallel.
  • Bundle size: first-party Tiptap extensions and elements are now in a single lazy chunk rather than spread across ~70 dynamic chunks. The bundle is fetched on demand by <umb-input-tiptap>, so workspaces that never open an RTE never pay for it.
  • Standalone usage: <umb-input-tiptap> and <umb-property-editor-ui-tiptap> remain importable / usable on their own and are not collapsed into the consolidated chunk. New test and stories cover the standalone path explicitly.
  • Data-type editor: the three configuration UIs (extensions-configuration, toolbar-configuration, statusbar-configuration) read manifest metadata only — they never call loadManifestApi — so the data-type editor still enumerates every Tiptap extension at boot without loading any implementation code. Verified by reading the configuration UI sources before designing the split.
  • Rich Text Essentials boot order: previously enforced via a static import + manual _extensions.push(...) before the observe. Now the alias is prepended to the observed list and the manifest's weight (1000) keeps it first in the resolved order. Same end-state, no static import.

Related to #21152

Replace the for…of/await loop in umb-input-tiptap's #loadExtensions with
Promise.all over .map, so all enabled Tiptap extension APIs are fetched
in parallel. Configured-extension order in _extensions is preserved.

Inline the first-party Tiptap manifest API references: every
`api: () => import('./X.tiptap-api.js')` and the equivalent toolbar /
statusbar / kind references now use a static top-of-file import and
`api: ClassName`. The dynamic `await import('rich-text-essentials.tiptap-api.js')`
fallback in input-tiptap.element.ts is inlined for the same reason.

External (plugin-supplied) Tiptap extensions and the lazy modal/toolbar
UI element imports are unchanged.

Why: on Umbraco Cloud, opening a document workspace with a rich text
editor takes ~16 s uncached, of which ~14.6 s is a single serial
waterfall — 31 extension APIs fetched one after the other from a
for…of await loop, ~170 ms RTT stacked. Replacing the loop with
Promise.all collapses that to roughly one round-trip; eagerly bundling
the first-party manifests removes the dynamic chunk explosion that made
the waterfall so long in the first place. The toolbar APIs (~20 of them)
already load in a sub-100 ms parallel burst against the same server,
confirming HTTP/2 multiplexing handles bulk parallel requests fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 27, 2026 07:32
@claude

claude Bot commented May 27, 2026

Copy link
Copy Markdown

Claude finished @iOvergaard's task in 4m 9s —— View job


PR Review

Target: origin/release/17.5.0 · Based on commit: 62e20481

Replaces the serial for…of await extension loader with Promise.all for parallel initialization, and converts all 40+ first-party Tiptap manifest api properties from lazy dynamic-import functions to direct static class references — collapsing ~90 dynamic chunk requests into the Tiptap package bundle.

  • Other changes: Extension APIs for all built-in Tiptap extensions are now eagerly included in the Tiptap package bundle rather than loaded as individual dynamic chunks. External (plugin-supplied) extensions using api: () => import(...) continue to work unchanged.

Important

  • src/Umbraco.Web.UI.Client/src/packages/tiptap/CLAUDE.md (not changed): The "Creating Extensions / Simple Extension (e.g., Bold)" example still shows api: () => import('./bold.tiptap-api.js') as the canonical pattern. Without updating this, future contributors who follow the guide will add new extensions with dynamic imports, gradually re-introducing the per-request waterfall this PR eliminates. The example should be updated to use api: UmbTiptapBoldExtensionApi (static reference) and a note added explaining why — so developers understand the performance contract when adding new first-party extensions.

Approved with Suggestions for improvement

The core changes are correct and well-executed. loadManifestApi already handles class constructors (checks property.prototype) so the type switch is safe. Promise.all preserves the manifest ordering in _extensions. No public API surface or manifest aliases were changed, and external plugins with dynamic-import api functions continue to work. The build, lint, and circular-dep checks all pass. Please consider updating the CLAUDE.md example before merging.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes the Backoffice Tiptap rich text editor’s startup by reducing network waterfalls: enabled Tiptap extension APIs are loaded in parallel, and first-party Tiptap manifest api references are switched from dynamic imports to static class references to reduce chunk/request explosion.

Changes:

  • Load enabled tiptapExtension APIs in umb-input-tiptap via Promise.all(...) while preserving the manifest array order when pushing into _extensions.
  • Inline first-party Tiptap manifest APIs by replacing api: () => import(...) with static imports and api: SomeApiClass.
  • Inline the Rich Text Essentials fallback import in umb-input-tiptap (now a static import + direct instantiation).

Reviewed changes

Copilot reviewed 42 out of 42 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts Loads enabled Tiptap extension APIs in parallel and inlines the Rich Text Essentials fallback API import.
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/anchor/manifests.ts Replaces dynamic-import API loaders with static imports/class references.
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/blockquote/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/bold/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/bullet-list/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/character-map/manifests.ts Replaces dynamic-import API loaders with static imports/class references (toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/clear-formatting/manifests.ts Replaces dynamic-import API loaders with static imports/class references (toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/code-block/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/manifests.ts Replaces dynamic-import API loaders with static imports/class references (core extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/embedded-media/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/figure/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/font-family/manifests.ts Replaces dynamic-import API loaders with static imports/class references (toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/font-size/manifests.ts Replaces dynamic-import API loaders with static imports/class references (toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + multiple toolbar buttons).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-attr-class/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-attr-dataset/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-attr-id/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-attr-style/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-tag-div/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-tag-span/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/image/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/italic/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/link/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar + unlink action).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/media-picker/manifests.ts Replaces dynamic-import API loaders with static imports/class references (toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/media-upload/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/ordered-list/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/strike/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-menu/style-menu.kind.ts Replaces dynamic-import API loader with static import/class reference (kind manifest).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/subscript/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/superscript/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts Replaces dynamic-import API loaders with static imports/class references for the table extension, toolbar, and menu actions.
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/text-align/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + multiple toolbar buttons).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/text-color/manifests.ts Replaces dynamic-import API loaders with static imports/class references (toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/text-direction/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + LTR/RTL toolbar buttons).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/text-indent/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + indent/outdent toolbar buttons).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/trailing-node/manifests.ts Replaces dynamic-import API loader with static import/class reference (extension).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/underline/manifests.ts Replaces dynamic-import API loaders with static imports/class references (extension + toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/undo-redo/manifests.ts Replaces dynamic-import API loaders with static imports/class references (toolbar undo/redo).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/view-source/manifests.ts Replaces dynamic-import API loader with static import/class reference (toolbar).
src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/word-count/manifests.ts Replaces dynamic-import API loader with static import/class reference (extension).

@claude claude Bot added area/frontend category/performance Fixes for performance (generally cpu or memory) fixes labels May 27, 2026
…rd manifests

Extends the manifest-inlining pass to the remaining `element: () => import(...)`
and runtime API loader sites in the Tiptap package — toolbar/menu/action-button
kinds, the table & character-map & anchor modals, the colour-picker button, the
property-editor configuration UIs, both clipboard translators, the style-menu
kind, and the default toolbar API fallback in tiptap-toolbar.element.ts.

Result on the same Cloud test site (uncached, 17.5-rc):
  Tiptap chunk count: 71 → 4
  Total tiptap bytes: ~3.2 MB → ~3.1 MB (essentially unchanged)
  Phase 5 of the load — the serial extension chain — collapses to a single
  consolidated chunk fetch.

`input-tiptap.element.ts` and `property-editor-ui-tiptap.element.ts` are
intentionally not inlined into anything else: `<umb-input-tiptap>` is a public
element usable standalone (custom dashboards, workspace views), and the
property-editor shell loads via the property-editor UI loader. They remain
exported as their own modules.

CLAUDE.md updated to document the new convention for first-party Tiptap
extensions (direct class refs) and the carve-out for external plugin
extensions that may keep `() => import(...)` to ship their API code in a
separate chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOvergaard and others added 2 commits May 27, 2026 14:24
…chunk

The previous PR collapsed ~70 Tiptap chunks into 3 by inlining first-party API
and element references directly into manifest files. That win came with a real
downside flagged in code review (lke / mra): the API/element implementation
bytes ended up in the manifest registration bundle, so every workspace —
including ones without an RTE — paid ~700 KB of Tiptap code on boot.

This commit keeps the chunk-coalescing win but restores the lazy boundary by
routing every first-party manifest's `api` / `element` reference through a
single shared bundle file `extensions/extension-apis.bundle.ts`. Each manifest
holds a dynamic-import thunk pointing at that one bundle, so:

- Rollup still emits a single chunk for all Tiptap extension code (no chunk
  explosion).
- The manifest registration bundle stays slim — it carries only metadata
  (alias / label / icon / group / kind / forExtensions) plus the thunks.
- The bundle is only fetched the first time `<umb-input-tiptap>` actually
  mounts.

Data-type configuration UIs (`extensions-configuration`,
`toolbar-configuration`, `statusbar-configuration`) read manifest metadata
via `umbExtensionsRegistry.byType(...)` only — they never call
`loadManifestApi` / `loadManifestElement`, so the data-type editor continues
to work without loading any Tiptap implementation code.

Property-editor UI elements (`tiptap-rte`, the three configuration UIs) also
revert to `() => import('./X.element.js')` so each loads on demand from its
own chunk rather than being inlined into the manifest bundle.

`umb-input-tiptap` no longer statically imports the Rich Text Essentials API;
it prepends the alias to the observed list instead, so essentials resolves
through the same lazy bundle as every other extension.

Added a test and stories file that mount `<umb-input-tiptap>` standalone (no
property-editor wrapper) to make the public usage pattern explicit.

Built and verified via `npm run build:for:cms`:
- `dist-cms/packages/tiptap/manifests.js`           48 KB  (eager at boot)
- `dist-cms/packages/tiptap/extension-apis.bundle-*.js` 84 KB  (lazy)
- `dist-cms/packages/tiptap/tiptap-toolbar-element-api-base-*.js` 654 KB
  (lazy dependency of the bundle)
- per-element property-editor UI chunks load on demand when settings open

`npm run check:circular`, `npm run compile`, `npx wtr src/packages/tiptap`
all pass.

Related to #21152, builds on #22995.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mounting the element via fixture() spins up an UmbTiptapRteContext that
consumes UMB_SERVER_CONTEXT. In the unit-test runtime no server context
provider exists, so the context request stays pending. When @open-wc's
fixture tears down at end-of-file the request rejects with
"host disconnected" — surfaced as an unhandled promise rejection that
web-test-runner counts as a fatal runner error, exiting 1 even though every
individual test passed. The rejection happened to be in flight while a
block-grid clipboard test was active in CI, which is why the failure surfaced
there rather than in the tiptap test file itself.

Drop the manifest-registration assertion too — pulling the package-level
`manifests.ts` aggregator triggers a transitive 404 on the
`@umbraco-cms/backoffice/tiptap` importmap entry in the wtr environment.

The class-export + custom-element-registration checks are enough to prove
standalone exportability. The Storybook stories still cover the visual
end-to-end load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@iOvergaard iOvergaard enabled auto-merge (squash) May 27, 2026 13:53
@iOvergaard iOvergaard merged commit ca28195 into release/17.5.0 May 27, 2026
25 checks passed
@iOvergaard iOvergaard deleted the v17/improvement/parallel-tiptap-extension-loading branch May 27, 2026 14:32
iOvergaard added a commit that referenced this pull request May 28, 2026
…22995)

* Tiptap: Load enabled extensions in parallel and inline manifest APIs

Replace the for…of/await loop in umb-input-tiptap's #loadExtensions with
Promise.all over .map, so all enabled Tiptap extension APIs are fetched
in parallel. Configured-extension order in _extensions is preserved.

Inline the first-party Tiptap manifest API references: every
`api: () => import('./X.tiptap-api.js')` and the equivalent toolbar /
statusbar / kind references now use a static top-of-file import and
`api: ClassName`. The dynamic `await import('rich-text-essentials.tiptap-api.js')`
fallback in input-tiptap.element.ts is inlined for the same reason.

External (plugin-supplied) Tiptap extensions and the lazy modal/toolbar
UI element imports are unchanged.

Why: on Umbraco Cloud, opening a document workspace with a rich text
editor takes ~16 s uncached, of which ~14.6 s is a single serial
waterfall — 31 extension APIs fetched one after the other from a
for…of await loop, ~170 ms RTT stacked. Replacing the loop with
Promise.all collapses that to roughly one round-trip; eagerly bundling
the first-party manifests removes the dynamic chunk explosion that made
the waterfall so long in the first place. The toolbar APIs (~20 of them)
already load in a sub-100 ms parallel burst against the same server,
confirming HTTP/2 multiplexing handles bulk parallel requests fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Inline element references for toolbar/statusbar/modal/clipboard manifests

Extends the manifest-inlining pass to the remaining `element: () => import(...)`
and runtime API loader sites in the Tiptap package — toolbar/menu/action-button
kinds, the table & character-map & anchor modals, the colour-picker button, the
property-editor configuration UIs, both clipboard translators, the style-menu
kind, and the default toolbar API fallback in tiptap-toolbar.element.ts.

Result on the same Cloud test site (uncached, 17.5-rc):
  Tiptap chunk count: 71 → 4
  Total tiptap bytes: ~3.2 MB → ~3.1 MB (essentially unchanged)
  Phase 5 of the load — the serial extension chain — collapses to a single
  consolidated chunk fetch.

`input-tiptap.element.ts` and `property-editor-ui-tiptap.element.ts` are
intentionally not inlined into anything else: `<umb-input-tiptap>` is a public
element usable standalone (custom dashboards, workspace views), and the
property-editor shell loads via the property-editor UI loader. They remain
exported as their own modules.

CLAUDE.md updated to document the new convention for first-party Tiptap
extensions (direct class refs) and the carve-out for external plugin
extensions that may keep `() => import(...)` to ship their API code in a
separate chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Move extension APIs and elements into a shared lazy boundary chunk

The previous PR collapsed ~70 Tiptap chunks into 3 by inlining first-party API
and element references directly into manifest files. That win came with a real
downside flagged in code review (lke / mra): the API/element implementation
bytes ended up in the manifest registration bundle, so every workspace —
including ones without an RTE — paid ~700 KB of Tiptap code on boot.

This commit keeps the chunk-coalescing win but restores the lazy boundary by
routing every first-party manifest's `api` / `element` reference through a
single shared bundle file `extensions/extension-apis.bundle.ts`. Each manifest
holds a dynamic-import thunk pointing at that one bundle, so:

- Rollup still emits a single chunk for all Tiptap extension code (no chunk
  explosion).
- The manifest registration bundle stays slim — it carries only metadata
  (alias / label / icon / group / kind / forExtensions) plus the thunks.
- The bundle is only fetched the first time `<umb-input-tiptap>` actually
  mounts.

Data-type configuration UIs (`extensions-configuration`,
`toolbar-configuration`, `statusbar-configuration`) read manifest metadata
via `umbExtensionsRegistry.byType(...)` only — they never call
`loadManifestApi` / `loadManifestElement`, so the data-type editor continues
to work without loading any Tiptap implementation code.

Property-editor UI elements (`tiptap-rte`, the three configuration UIs) also
revert to `() => import('./X.element.js')` so each loads on demand from its
own chunk rather than being inlined into the manifest bundle.

`umb-input-tiptap` no longer statically imports the Rich Text Essentials API;
it prepends the alias to the observed list instead, so essentials resolves
through the same lazy bundle as every other extension.

Added a test and stories file that mount `<umb-input-tiptap>` standalone (no
property-editor wrapper) to make the public usage pattern explicit.

Built and verified via `npm run build:for:cms`:
- `dist-cms/packages/tiptap/manifests.js`           48 KB  (eager at boot)
- `dist-cms/packages/tiptap/extension-apis.bundle-*.js` 84 KB  (lazy)
- `dist-cms/packages/tiptap/tiptap-toolbar-element-api-base-*.js` 654 KB
  (lazy dependency of the bundle)
- per-element property-editor UI chunks load on demand when settings open

`npm run check:circular`, `npm run compile`, `npx wtr src/packages/tiptap`
all pass.

Related to #21152, builds on #22995.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Don't mount <umb-input-tiptap> in the standalone test

Mounting the element via fixture() spins up an UmbTiptapRteContext that
consumes UMB_SERVER_CONTEXT. In the unit-test runtime no server context
provider exists, so the context request stays pending. When @open-wc's
fixture tears down at end-of-file the request rejects with
"host disconnected" — surfaced as an unhandled promise rejection that
web-test-runner counts as a fatal runner error, exiting 1 even though every
individual test passed. The rejection happened to be in flight while a
block-grid clipboard test was active in CI, which is why the failure surfaced
there rather than in the tiptap test file itself.

Drop the manifest-registration assertion too — pulling the package-level
`manifests.ts` aggregator triggers a transitive 404 on the
`@umbraco-cms/backoffice/tiptap` importmap entry in the wtr environment.

The class-export + custom-element-registration checks are enough to prove
standalone exportability. The Storybook stories still cover the visual
end-to-end load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOvergaard added a commit that referenced this pull request May 28, 2026
PR #22995 added `input-tiptap.stories.ts` with an import from
`'../../manifests.js'`, but PR #22957 (already on release/17.5.0) had
deleted that file and moved the `manifests` array into
`umbraco-package.ts`. The merge into release/17.5.0 didn't catch the dead
import, so Storybook 404s on the story load.

Point the import at the new home — `manifests` is still exported by name,
so this is a one-line path fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOvergaard added a commit that referenced this pull request May 28, 2026
PR #22995 added `input-tiptap.stories.ts` with an import from
`'../../manifests.js'`, but PR #22957 (already on release/17.5.0) had
deleted that file and moved the `manifests` array into
`umbraco-package.ts`. The merge into release/17.5.0 didn't catch the dead
import, so Storybook 404s on the story load.

Point the import at the new home — `manifests` is still exported by name,
so this is a one-line path fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@iOvergaard iOvergaard changed the title Tiptap: Load enabled extensions in parallel and inline manifest APIs Performance: Fix bottleneck when a Tiptap editor appears on a document May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants