Skip to content

Backoffice: Unified manifests boot (AB#63380)#22903

Closed
iOvergaard wants to merge 14 commits into
v17/devfrom
v17/improvement/63380-unified-manifests-boot
Closed

Backoffice: Unified manifests boot (AB#63380)#22903
iOvergaard wants to merge 14 commits into
v17/devfrom
v17/improvement/63380-unified-manifests-boot

Conversation

@iOvergaard

@iOvergaard iOvergaard commented May 20, 2026

Copy link
Copy Markdown
Contributor

Prerequisites

  • I have added steps to test this contribution in the description below

Part of GH #21152 — backoffice load performance roadmap. Builds on PR #22896 (chunk coalescing).

Status — infrastructure only

Important

The runtime switch (app.element.ts dropping CORE_PACKAGES for a single @umbraco-cms/backoffice/manifests-all import) was attempted, reverted, and is now deferred. See the "Known issue" section below for the cause and the three candidate fixes.

What this PR ships: the merged-manifests build infrastructure (Vite plugin, build script, importmap exposure, type stub) as additive groundwork, plus a normalisation that moves three packages' inlined backofficeEntryPoint extensions from umbraco-package.ts into their manifests.ts for consistency with the other 36 packages.

Description

Adds a build-time Vite plugin that walks src/packages/*/manifests.ts, generates a virtual entry aggregating every package's manifests into a flat allManifests array, and emits one merged dist-cms/manifests-all/index.js. The artifact is exposed through package.json exports as @umbraco-cms/backoffice/manifests-all. Nothing imports it at runtime in this PR — that's the deferred follow-up.

Three packages (media, property-editors, templating) declared backofficeEntryPoint extensions inline in their umbraco-package.ts instead of manifests.ts. This PR moves them to manifests.ts (with side-effect imports for the two whose entry-point.ts had no onInit, and as a proper backofficeEntryPoint manifest for property-editors which has a real init hook). The bundle initializer continues to load them per-workspace, so timing is unchanged.

Design + plan

  • Design: docs/superpowers/specs/2026-05-20-unified-manifests-design.md
  • Plan: docs/superpowers/plans/2026-05-20-unified-manifests.md

Known issue: custom-element duplication (runtime switch deferred)

When the runtime switch was attempted, the backoffice failed to boot with NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name "umb-block-action-list" has already been used with this registry.

Root cause. The merged manifests-all Rollup graph statically reaches the implementation source of many custom elements (via the manifest sub-tree of each package — e.g. block/manifests.ts → sub-feature manifests → store / context-token modules → element side-effect imports). Rollup bundles those element sources into chunks under dist-cms/manifests-all/. The per-workspace builds also emit the same elements into dist-cms/packages/<pkg>/. At runtime, when something in the merged graph hits an externalised @umbraco-cms/backoffice/<pkg> import, the importmap resolves it to the per-workspace chunk, which calls customElements.define(...). Then the merged graph's own copy fires the same define and crashes.

Three candidate fixes (open):

  1. Externalise relative imports beyond the manifest boundary in the merged build so element source isn't bundled twice. Boundary is fuzzy and brittle to express in Rollup config.
  2. Skip per-workspace builds for content that lands in manifests-all → breaks the plugin contract.
  3. Bundle-indirection (Step 1 from the original brainstorm): the merged file holds bundle wrappers only, and manifests.ts continues to load per-workspace via the bundle initializer's js() callback. Smaller win (saves one wave, not both) but no duplication.

How to test

  1. cd src/Umbraco.Web.UI.Client && npm install
  2. npm run build:for:cms — confirm dist-cms/manifests-all/index.js is emitted and the importmap contains @umbraco-cms/backoffice/manifests-all.
  3. dotnet run --project src/Umbraco.Web.UI from the repo root.
  4. Log in to the backoffice and confirm it behaves exactly as v17/dev does (boots, navigates, opens documents) — the merged artifact is unused at runtime, so behaviour should be identical.

Verification done before opening this PR

  • npm run lint:errors clean
  • npm run compile clean
  • npm run check:circular clean
  • npm run check:module-dependencies unchanged (14/15 bidirectional imports, same as before)
  • node --test devops/build/vite-plugin-unified-manifests.test.mjs 5/5 pass
  • npm run build:for:cms completes end-to-end
  • Local Playwright smoke: backoffice boots, Content section renders, document editor opens.
  • WAN TTFE: 4625 ms median over 3 runs (within noise of the 4394 ms post-PR-Performance: Coalesce JavaScript chunks around the Backoffice to reduce the number of requests #22896 baseline — confirms no regression from the additive infrastructure).

Rollback

The runtime switch was already reverted within this PR. The remaining infrastructure is additive — if shipping just the build artifact + importmap entry is undesirable, the entire PR can be reverted with no impact on existing behaviour.

🤖 Generated with Claude Code

iOvergaard and others added 14 commits May 19, 2026 21:55
…983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.
Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the measured comparison between today's two-wave boot manifest
layer (187 eager chunks, 267 KB gzip) and a Step 2 unified-merge target
(36 eager chunks, 208 KB gzip), proposes the Vite plugin shape, the
app.element.ts boot change, dev-server behaviour, the measurement gate
via measure-ttfe.mjs, and the rollback path.

Notes that the previously-assumed prerequisite codemod (#68481, by-value
manifests imports → lazy) is no longer a blocker: trial measurements
showed by-value classes are already in the eager set today, so merging
is byte-neutral.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-task plan covering the Vite plugin (with TDD on the entry-source
generator), build wiring, importmap exposure, app.element.ts switch,
and end-to-end smoke + measurement. Each task is bite-sized with
exact commands and code blocks for the implementing agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a build-time plugin that walks src/packages/*/manifests.ts
and exposes a virtual:unified-manifests module aggregating every
package's manifests export into one allManifests array. Includes
HMR invalidation so dev mode picks up manifest edits.

Pure entry-source generator covered by node --test.
Dedicated Vite config consumes the unified-manifests plugin and emits
dist-cms/manifests-all/index.js. Slotted between build:workspaces and
generate:manifest in build:for:cms so the importmap step picks up the
new artifact.
Adds the new artifact to package.json exports so the importmap pipeline
auto-includes it at @umbraco-cms/backoffice/manifests-all.
Drops the 37-entry CORE_PACKAGES dynamic-import array in favour of one
import of @umbraco-cms/backoffice/manifests-all. The merged artifact
contains every first-party package's manifests, eliminating the
bundle-initializer second wave for first-party packages.

Third-party umbraco-package.ts entries are unaffected — the bundle
extension type is still supported for plugin authors.
- Plugin: track every discovered manifest path via this.addWatchFile so
  brand-new packages created during a dev session trigger HMR.
- Plugin: document the alphabetical determinism on discoverPackages.
- Build config: comment the preserveEntrySignatures rationale next to the
  lib: undefined comment.
- app.element.ts: explicit .catch handler logging the underlying import
  failure before the generic 'Extensions failed loading' page swallows it.
- Stub: move the import-type line above the file-header comment block.

No behaviour change in the happy path; surfaces clearer diagnostics and
catches a dev-mode HMR edge case.

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

- Plugin: collapse the dual `discovered` + `cachedSource` closure state into
  a single nullable `cache` record so the invariant (entries↔source) is
  obvious from one variable.
- Plugin: unexport `toIdentifier` and `discoverPackages` — they were never
  consumed externally. The pure public surface is now `buildEntrySource`
  and `unifiedManifestsPlugin` only.
- Plugin: compute each package's identifier once via a pre-mapped list
  instead of twice across the two `.map` passes in `buildEntrySource`.
- Plugin: `discoverPackages` now uses `readdirSync({ withFileTypes: true })`
  + a single `statSync` on the manifest path, halving syscalls per
  package and removing the existsSync/statSync TOCTOU pattern.
- Plugin: `handleHotUpdate` no longer re-walks the packages directory for
  content edits to an already-known file — only rebuilds the entry source.
  A full rediscover only fires when a new manifests.ts file appears. Also
  short-circuits the virtual-module invalidation when the rebuilt source
  is identical to the previous one (no-op saves don't cascade through
  Vite's module graph).
- Vite config: drop the dead Array.isArray branch on baseOutput in favour
  of a typed cast, since getDefaultConfig contractually returns a single
  output object.

No behaviour change in the happy path; ~halves dev-server syscall load on
HMR and tightens the module's public surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the cache-rebuild branch into a pure helper nextCacheForChange so
the hook body is straight-line code (early-return guard, rebuild, source
diff, invalidation). CodeScene flagged the previous implementation at
cyclomatic complexity ≥10; TypeScript threshold is <9.

No behaviour change.

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

The build:manifests-all step failed on Windows CI because absolute paths
produced by node:path.join use backslash separators. The plugin embedded
those paths directly into a JS template literal, so Rollup saw escape
sequences like \a, \1 inside string literals — "Legacy octal escape" and
related parse errors.

Wrap the path with JSON.stringify in buildEntrySource so any character
(backslashes, quotes, control chars) is correctly encoded. Added a
regression test that asserts a Windows-style D:\… path is emitted as
"D:\\…" in the generated source.

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

Three packages (media, property-editors, templating) declared
backofficeEntryPoint extensions inline in their umbraco-package.ts files
rather than in manifests.ts. The unified-manifests boot path only reads
each package's manifests.ts, so these three entry points were silently
dropped — which broke E2E smoke tests on Windows where auth.setup.ts
timed out waiting for the backoffice chrome to render after login.

Resolved per entry-point shape:

- media: the entry-point file only re-exports components. Moved the
  components/index.js side-effect import into manifests.ts directly
  and dropped the backofficeEntryPoint manifest entry.
- templating: same shape (pure side-effect imports), same treatment.
- property-editors: has a real onInit hook (proxying the entity-data-
  picker init). Kept the backofficeEntryPoint manifest in manifests.ts
  using the static `import * as entryPointModule` form, matching the
  webhook/language/user convention. Added a no-op `onUnload` export so
  the module satisfies UmbEntryPointModule's required shape.

All three packages' umbraco-package.ts files now collapse to the
uniform bundle-wrapper shape used by the other 36 first-party packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The merged manifests-all build successfully consolidates manifest
metadata, but the runtime switch (app.element.ts → single
import('@umbraco-cms/backoffice/manifests-all')) hit a deeper issue
discovered by Windows E2E tests:

The merged Rollup graph reaches element implementation source through
manifest sub-trees (manifests.ts → sub-package manifests → store
classes → side-effect element imports). Rollup bundles those elements
into chunks under dist-cms/manifests-all/. The per-workspace builds
also emit the same elements under dist-cms/packages/<pkg>/. At runtime,
externalised @umbraco-cms/backoffice/<pkg> imports resolve to the
per-workspace copy, which calls customElements.define; then the
merged graph's own copy fires the same define and the browser
throws "NotSupportedError: ... has already been used with this
registry".

Restoring CORE_PACKAGES + the original #registerExtensions /
#loadCurrentUser, so this PR ships purely as additive infrastructure:

  - vite-plugin-unified-manifests + tests
  - manifests-all.vite.config.ts + build:manifests-all script
  - package.json exports + tsconfig path for @umbraco-cms/backoffice/manifests-all
  - type stub at src/manifests-all/index.ts

The artifact is built and reachable but unused at runtime. Updating
the design doc to capture the deferred runtime switch and the three
candidate paths for resolving the duplication.

The backofficeEntryPoint normalisation (media/property-editors/
templating) is kept — the declarations now live in their respective
manifests.ts files. The bundle initializer continues to load them
per-workspace, so timing is the same as before.

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

Copy link
Copy Markdown
Contributor Author

Closing unmerged. The runtime switch (single import of @umbraco-cms/backoffice/manifests-all replacing CORE_PACKAGES) was attempted on this branch and reverted — it hit customElements.define collisions because the merged Rollup graph statically reaches element implementation source through leaky barrel index.ts files, while the per-workspace dists ship the same elements. Both load at runtime, double-define, browser throws.

That blocker is documented in detail on ADO #63380's comment 28132245, along with the realisation that Phase 1A (removing side-effect imports from barrels) is per-barrel deprecation work, not a line-removal sweep. The manifest unification has to wait for that cleanup.

The branch v17/improvement/63380-unified-manifests-boot is preserved on origin in case anyone wants to resurrect the Vite plugin / build config / importmap entry once enough barrels are cleaned up. The two normalisations that came out of this work (documents/umbraco-package.ts lazy-bundle pattern; umbraco-news/manifests.ts export const manifests shape) already rode along with PR #22896 (still open) since they were committed to its parent branch first.

🤖 Closed via Claude Code.

@iOvergaard iOvergaard closed this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant