Skip to content

feat(extensibility): plugin externals contract, runtime plugin loading, rsbuild 2 pipeline, and create-ohif scaffolding - #6168

Open
sedghi wants to merge 98 commits into
masterfrom
ohifNextExtensions
Open

sedghi wants to merge 98 commits into
masterfrom
ohifNextExtensions

Conversation

@sedghi

@sedghi sedghi commented Jul 20, 2026 •

Copy link
Copy Markdown
Member

What this branch does

A rework of how extensions and modes are built, distributed, and loaded, so plugins can live outside the monorepo:

  • Plugin externals contract: .rspack/pluginExternals.js is the single canonical list of packages the host shares with plugins at runtime; per-package builds, scaffolded templates, and the runtime loader all derive from it, with parity tests (platform/create-ohif/tests/externals-parity.test.mjs) enforcing that template externals, host-provided globals, and template peerDependencies stay in sync.

    • This fixes a live defect: master's published @ohif/core, @ohif/extension-default, and @ohif/extension-cornerstone (through 3.14.0-beta.36) each bundle a full copy of React because their per-package externals omitted it. The tarball guard could not detect this because CI's quick builds produce no source maps; verify-tarballs now also scans bundle text for React markers, and the PR gate builds with maps.
  • rsbuild 2 pipeline consolidation: the .webpack config dirs are renamed to .rspack, rsbuild is upgraded to 2.x, and production and dev builds converge on a single repo-root rsbuild.config.ts pipeline (fonts as native asset modules, e2e coverage instrumentation restored, singleton aliases deduped, Tailwind made plugin-aware).

  • Runtime plugin loading with gating: prebuilt UMD plugins declared in window.config.extensions[] load through runtime plugin descriptors with host-shared globals and an audit trail; dynamic loading is gated, invalid pluginConfig.json fails fast against a JSON schema, and dangerouslyUseDynamicConfig requires an explicit regex.

    • Built-in plugins share themselves. Every extension and mode compiled into the viewer is put on window under its package name (whole module, named exports included) once it has loaded, so UMD runtime plugins can import them. These globals carry no compatibility promise; the SDK list in runtimeShared.ts stays the stable contract. Within one load, built-in plugins load before runtime plugins. Extensions marked "default": false load only when a mode needs them; to import one from a runtime plugin at startup, list it by name in window.config.extensions (see Runtime plugins in the docs).
  • create-ohif scaffolding: pnpm create ohif templates for extensions, modes, workspaces, and deployment, verified end to end by a scaffold smoke harness (scaffold, install, build, UMD global contract, in-scaffold tests).

  • ohif-plugin CLI helper: one-command add/remove of plugins plus a doctor check; the legacy OHIF CLI is removed and docs repointed at the pluginConfig flow.

  • Publish surface tiering: only the SDK packages (@ohif/core, @ohif/ui-next, @ohif/i18n, @ohif/extension-default, @ohif/extension-cornerstone) remain publishable; the rest of the workspace is marked private.

    • @ohif/ui is deprecated and no longer published. The legacy UI library is frozen and marked private: true; no 3.14.0 or later version will appear on npm. It is also not on the host-shared list, so runtime plugins cannot import it. Integrators who depend on @ohif/ui should port to @ohif/ui-next (the APIs differ). create-ohif migrate flags every @ohif/ui import and peer dependency it finds and says so. Inside the viewer, @ohif/ui remains a dependency of platform/app only for the deprecated LegacyWorkList route.
  • Docs: guides for out-of-tree development, scaffolding, publishing, runtime plugins and CSP deployment, porting/de-forking, and the extension gallery.

Merge Order / Rebase Details

Rebased onto master after #6164 landed. The reconcile keeps the .rspack/ layout and the single rsbuild pipeline, removes REACT_COMPILER=off from the UMD builds per master's compiler policy, pins Rspack to the 2.1.10 that @rsbuild/core bundles, and restores LegacyWorkList behind workList.variant following master's deprecation plan.

Testing

  • platform/create-ohif suite and the app contract suites pass locally.
  • node platform/create-ohif/scripts/scaffold-smoke.mjs is the documented manual end-to-end gate for the scaffolding flow.

Testing Performed

Verified locally on the merged tree:

  • Production build and preview, including the legacy work list.
  • The scaffold smoke harness: a fresh extension and mode install, build, and test under React 19.
  • Docker: an image from this branch serving a scaffolded mode from /plugins/, with cache headers, 404 behavior, the version gate, and CSP on and off checked. The doc fixes in the d4d219b commit came from this run.

Summary by CodeRabbit

  • New Features

    • Added runtime loading for extensions and modes without rebuilding the viewer, with origin controls, integrity checks, compatibility validation, and failure notifications.
    • Added create-ohif to scaffold workspaces, extensions, modes, and deployments, and migrate older plugins.
    • Added commands to add, remove, link, unlink, list, and check configured plugins.
  • Security

    • Improved plugin asset security headers, MIME handling, caching, and missing-file responses. Dynamic configuration URLs now require origin or anchored-pattern validation.
  • Build and Publishing

    • Updated package builds and publishing checks for the UMD plugin format.
  • Documentation

    • Added guides for runtime plugins, scaffolding, publishing, migration, deployment, and the extension gallery.
  • Migration

    • Removed the legacy OHIF CLI. Use create-ohif and the plugin management commands instead.

sedghi added 30 commits July 20, 2026 12:39
…ntime

- Remove the LegacyWorkList route and the workList.variant customization;
  WorkList is now always mounted at /
- Drop the now-unused @ohif/ui workspace dependency from 11 packages
- Switch @babel/preset-react to the automatic runtime in all config blocks,
  matching what the rsbuild/SWC dev pipeline already produced
- Set tsconfig jsx to react-jsx
- Replace platform/ui-next's drifted babel.config.js with the standard
  re-export of the root config

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
- Bump react/react-dom to 19.2.7 (exact pins) in all packages and add
  pnpm overrides so a single copy is guaranteed under the hoisted linker
- @types/react 19.2.17, @types/react-dom 19.2.3
- @ohif/ui-next: react moves from dependencies to peerDependencies (^19)
- platform/ui: react out of dependencies (UMD externals), peers widened,
  PortalTooltip ported from legacy ReactDOM.render to createRoot
- @testing-library/react 16.3.2 (v13 relied on react-dom/test-utils,
  removed in react-dom 19); react-test-renderer and framer-motion deleted
  (zero imports)
- next-themes 0.4.6, lucide-react 0.577.0, react-resize-detector 12.3.0,
  docs react-day-picker 9.12.0
- Apply types-react-codemod preset-19 (useRef initial values,
  ReactElement generics)

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
The rsbuild config that already powered dev:fast is now production-ready
and pnpm run build produces the app dist through it. The rspack pipeline
stays available as build:legacy (rollback) and keeps powering the classic
dev servers and the e2e webServer until those migrate.

- Extract InjectServiceWorkerManifestPlugin into a shared file that takes
  its bundler APIs from compiler.webpack, so the same plugin runs under
  the rspack versions bundled by both pipelines
- Legacy-compatible dist layout: bundles at the dist root named
  [name].bundle.<hash>.js, HTML emitted as index.html
- Disable rsbuild's built-in publicDir copy (it copied all of public/
  including config/ and html-templates/); replicate the selective copy
  with explicit patterns instead
- Parity with webpack.base.js: optimization.sideEffects false, noParse
  for dicomicc, fullySpecified off for .m?js, mainFields order, prod
  source-map devtool template, QUICK_BUILD support, TEST_ENV define,
  mode-dependent APP_CONFIG default, HTML_TEMPLATE/ENTRY_TARGET envs
- legalComments none to match the rspack output (no *.LICENSE.txt in
  dist or the sw.js precache manifest)
- Add analyze script (RSDOCTOR=true)

Verified against the rspack output: identical file sets modulo the
documented vendor-split chunks and static/* asset layout, byte-identical
app-config.js, correctly prefixed sw.js manifest under PUBLIC_URL
subpaths, rollbar HTML template switch, and a served-dist smoke test.
Accepted diffs: HTML is no longer minified (rsbuild 1.x needs a plugin
for that) and CSS is now minified (legacy prod CSS was not).

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
- babel.config.js: babel-plugin-react-compiler (target 19) runs first,
  covering the rspack legacy build, classic dev, jest, and the UMD
  package builds; REACT_COMPILER=off is the kill switch
- rsbuild.config.ts: scoped @rsbuild/plugin-babel pass on workspace
  source (platform/extensions/modes src, excluding frozen platform/ui)
  layered on SWC for dev:fast and the production build; preset-typescript
  is reset to infer TS vs TSX per file extension because the plugin's
  forced isTSX rejects legal plain-.ts syntax (angle-bracket casts)
- UMD package builds (ui, ui-next, core, i18n) run with
  REACT_COMPILER=off: their externals cover react/react-dom only, not
  react/compiler-runtime, so compiled output would inline React
- New eslint.config.mjs (flat, eslint 10 + eslint-plugin-react-hooks 7)
  with the compiler-powered rules and a lint:compiler script; the
  diagnostics list is the do-not-touch gate for the upcoming manual
  memoization cleanup (baseline: 292 problems, 184 errors)
- Ignore the font asset the ui-next UMD build emits at the package root

Verified: compiled components present in 14 production bundles
(memo_cache_sentinel), absent from the ui-next UMD dist; jest suite
green with the compiler active; both dev servers boot and serve.

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
…oization

forwardRef -> ref-as-prop across all 28 component files (90 sites):
components take ref as a regular prop typed via React.ComponentProps
(which includes ref under the react 19 types) or an explicit
ref?: React.Ref<...>; displayName assignments and useImperativeHandle
bodies preserved.

propTypes removed from all 27 files that still carried runtime
prop-types (TypeScript types already cover them); the
ProgressDropdownOptionPropType export and its imports removed with it.

Manual memoization removed only where proven redundant: 50 useCallback/
useMemo sites across 21 files that (a) carry zero compiler-lint
diagnostics and (b) were verified compiled by running the production
babel transform per file and checking for memo-cache slots. Files the
compiler bails on or does not recognize (factory-created components in
lib/createContext, Clipboard, WorkflowsProvider, and the 22 files with
compiler diagnostics) keep their manual memoization, as does the
debounce-wrapping useMemo in InputFilter (recreating a debouncer per
render would drop pending calls). SmartScrollbar's React.memo trio
stays: SmartScrollbar.tsx fails its compiler gate.

eslint.config.mjs now bans forwardRef and prop-types imports in
ui-next scope so the removed patterns do not creep back in.

New exhaustive-deps warnings (~18) are the classic rule not modeling
compiler memoization of unwrapped effect dependencies - the affected
files are all verified compiled, so effect cadence is unchanged at
runtime; error-level lint surface is unchanged vs the phase-3 baseline.

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
propTypes removed from the remaining 33 files across platform/app,
platform/core, and all extensions (TypeScript types already cover
them), and the prop-types dependency dropped from 15 package.jsons
(kept in frozen platform/ui). MicroscopyPanel's interface used
PropTypes members as TS types - replaced with real types.

Manual memoization removed from the 18 files that pass both gates
(zero compiler-lint diagnostics AND per-file verified compiler
coverage): ~50 useCallback/useMemo sites across extensions/cornerstone,
extensions/default, extensions/dicom-microscopy, platform/core, and
platform/app hooks. The 45 other memoization-carrying files keep theirs
(compiler bailouts or unrecognized components), as does
ViewportWindowLevel's debounce chain.

Every extension and mode UMD build script now runs REACT_COMPILER=off,
matching the platform packages: their externals do not cover
react/compiler-runtime, and the cornerstone extension UMD was found
bundling it (verified absent after gating).

Compiler-health guard rules (no forwardRef, no prop-types) now apply
across the whole workspace, and a lint budget ratchet
(scripts/reactCompilerLintBudget.mjs + .react-compiler-lint-budget.json,
186 errors / 135 warnings) runs in the CircleCI UNIT_TESTS job so the
diagnostic count can only go down. Also removed a stale @types/react 18
entry from platform/app dependencies.

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
…rfaced

ManagedDialog mutated its position object in place and depended on a
later render observing the mutation: compiled memoization kept
explicitly positioned dialogs (e.g. the measurement context menu)
clipped at the viewport edge, and the in-place mutation was also
masking an infinite-setState loop in the dialog ref chain (the ref is
re-attached on renders because useDraggable composes an unmemoized
ref). Positions are now immutable, the state update bails out on equal
coordinates, and the measure/reposition/reveal runs in a layout effect
after content layout but before paint.

PanelSegmentation read customizations once per render via
customizationService.getCustomization, which races mode onModeEnter
registrations: TMTV replaces panelSegmentation.onSegmentationAdd with
its create-labelmap-from-PT handler, and when the panel's first render
preceded that registration the compiler memoized the stale default
handler permanently, so the segmentation was created from CT, SUV
statistics (incl. lesion glycolysis) were never computed, and the TMTV
CSV export crashed. The new useCustomization hook in @OHIF/core
subscribes to the customization-modified events so consumers converge
on the registered value regardless of mount order; PanelSegmentation
now uses it for all of its customization reads.

Verified with the previously failing Playwright specs: ContextMenu and
TMTVCSVReport (5/5 with the compiler on).

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
…tomization

useViewportHover is restored to its pre-cleanup form. The sweep had
unwrapped setupListeners while leaving it in the effect dependency
array, so every render re-attached the document-level mousemove/resize
listeners; under the resulting churn the toolbar overlay and hotkey
paths intermittently never dispatched their commands (rotate/flip/reset
e2e failures). The manual memoization here is load-bearing and this
file is excluded from the compiler-era cleanup.

useCustomization now subscribes only to MODE_CUSTOMIZATION_MODIFIED:
mode-scope registrations (mode.onModeEnter) are the ones that race
component mounting, while global and default customizations are
registered before the app renders. PanelSegmentation reads only
panelSegmentation.onSegmentationAdd through the hook - the key TMTV
overrides after mount - and keeps direct getCustomization reads for the
five customizations that are registered before panels can mount, which
keeps the panel's re-render surface unchanged.

Verified with the Playwright regression targets: ContextMenu and
TMTVCSVReport pass; jest suite and production build green.

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
The COVERAGE babel rule in .webpack/webpack.base.js supplied its own
inline presets (@babel/preset-typescript + classic-runtime
@babel/preset-react) plus istanbul. Because babel-loader still loads the
root babel.config.js, that inline classic-runtime preset-react shadowed
the automatic-runtime one and, with it, babel-plugin-react-compiler
never took effect in coverage builds.

Every COVERAGE build (Cypress e2e, the Playwright e2e webServer, and
coverage unit runs) therefore shipped the compiler-era cleanup
components without the memoization the compiler is supposed to restore.
Context providers whose manual useMemo was removed produced a new
context value every render, cascading re-renders that broke behavior the
production and dev:fast builds get right - most visibly the viewport
orientation markers not updating after rotate/flip/reset, which is what
the OHIFCornerstoneToolbar and OHIFCornerstoneHotkeys cypress specs
assert.

The rule now mirrors the non-coverage dev rule: delegate to the root
babel config (which carries the compiler) and add only istanbul, so the
coverage/e2e build exercises the same compiled output that ships.

Verified: the previously failing OHIFCornerstoneToolbar and
OHIFCornerstoneHotkeys cypress specs pass (10/10) with this change.

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
The BUILD_PACKAGES_QUICK security-audit step runs pnpm audit
--audit-level high only when pnpm-lock.yaml changes versus master. This
PR touches the lockfile (the React 19 dependency bumps), so the step
runs and trips on GHSA-xcpc-8h2w-3j85 (adm-zip), a pre-existing tree
entry pulled transitively through dcmjs (@cornerstonejs) that master
carries but never gates. dcmjs is unchanged here (pinned 0.52.0), so
this advisory is not introduced by this PR; add it to the accepted
ignoreGhsas list alongside the existing entry.

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
…piler

Root cause of the e2e failures (OHIFCornerstoneToolbar / OHIFCornerstoneHotkeys
rotate/flip/reset): the React Compiler miscompiles the components under
extensions/cornerstone/src/Viewport/. Those components read and mutate
external, non-React state during render and through imperative cornerstone3D
event handlers (the enabled element, the camera via canvasToWorld, GL actors).
The compiler's memoization assumes referential purity, so the compiled output
silently stops recomputing - most visibly ViewportOrientationMarkers keeps the
pre-transform letters after a rotate/flip/reset even though the command ran and
the camera changed.

Bisected with a deterministic oracle in a clean worktree: the failure appears
exactly at the compiler-enablement commit (the prior commit passes), reproduces
with the compiler on, and disappears when extensions/cornerstone/src/Viewport is
excluded from it. A "use no memo" directive on the marker alone was
insufficient because the miscompiled component is the viewport wrapper, so the
whole Viewport directory is scoped out. The rest of the workspace keeps the
compiler.

Applied to both pipelines: a babel overrides-exclude for the rspack path
(dev / classic dev / coverage e2e / rspack builds) and a matching
@rsbuild/plugin-babel exclude for dev:fast and the rsbuild production build.

Verified: rotate, flip, and reset all update the orientation markers with this
exclusion (previously stale); the compiler still applies everywhere else.

Claude-Session: https://claude.ai/code/session_01KKhdKHKU26suaTRHFVyXdR
…ponent

useWorkListToolbarActions invoked the ohif.dataSourceConfigurationComponent
customization as a plain function, which spliced that component's hooks
(useTranslation, useModal, useState/useEffect) into the caller's hook list
and broke the Rules of Hooks — the WorkList route crashed with a hook-order
violation and 'Cannot read properties of undefined (reading length)'.
Render it as a component so its hooks get their own fiber, and gate the
early return on the customization's presence instead of its render output.
ViewportSliceProgressScrollbar rebuilt imageIds and the imageIdToIndex Map
on every render. The component lives in the Viewport directory that is
excluded from the React Compiler, so nothing auto-memoizes them anymore;
the byte-array seeding effects in useLoadedSliceBytes/useViewedSliceBytes
list them as deps, re-ran each render, and their version bump re-rendered
the component in an infinite loop (continuous 'Maximum update depth
exceeded' errors on every viewer route). Same class of fix as the
useViewportHover memoization restore.
Bump @rsbuild/core 1.7.3 -> 2.1.6, plugin-react -> 2.1.0, plugin-babel ->
2.0.1, plugin-node-polyfill -> 1.4.6. The config surface is unchanged in
v2 except server.host, whose default flipped from 0.0.0.0 to localhost —
pin it to keep the LAN Network URL.

Rename the entry from app to index: rsbuild derives the dev-server route
and printed URL from the entry name alone (only 'index' maps to '/'), so
the old entry served and printed http://localhost:3000/app. The emitted
bundles keep the rspack build's app.bundle.<hash>.js / app.bundle.css
naming via function-form output.filename, and the index.html filename
override is now redundant ([name].html already yields index.html).
…normalize mode main fields

All 26 per-package prod configs now import the shared externals contract
(react/react-dom/react/jsx-runtime added as externals everywhere). Fixes the
ohif-ui-next UMD library name collision with @ohif/ui. Normalizes the four
non-conforming mode main fields to dist/ohif-<name>.umd.js and rewrites
modes/segmentation's legacy standalone config to the standard mode shape.
pluginConfig.json gains a $schema pointer; the never-read version fields are
removed. Schema is draft-07 with additionalProperties:false.
…ce-worker bypass

Both nginx templates gain an opt-in CSP_HEADER add_header (envsubst allowlist
updated) and a /plugins/ location block with explicit MIME types, cache tiers,
and no SPA fallback. The service-worker precache excludes plugins/ and a
NetworkOnly route bypasses /plugins/ fetches (rspack.pwa.js only; the rsbuild
dev pipeline generates no service worker). netlify.toml's Report-Only header
now mirrors the documented baseline with interim unsafe-inline.
…nConfig

runtimeExtensionLoader.ts is the single gate for the loadModule fallthrough:
URL-shaped specifiers must pass a deny-by-default origin allowlist (same-origin
implicit, extras via window.config.runtimeExtensionOrigins) and unknown bare
names throw a descriptive error. The codegen emits the gated epilogue and the
config is structurally validated at build with actionable messages.
pnpm plugin add installs via pnpm and writes the pluginConfig entry in one
step; doctor validates declared plugins, peer ranges against version.txt,
dangling references, and singleton copies in directory plugins.
The published set is now exactly the shared-surface packages (@OHIF/core,
@ohif/ui-next, @ohif/i18n, @ohif/extension-default, @ohif/extension-cornerstone)
with repaired tarball metadata (publishConfig dist rewrites, files, keywords,
peer hygiene). The remaining extensions, all modes, and @ohif/ui are
private: true and stay version-stamped. verify-tarballs enforces the tier
invariant and publish-list parity; verify-umd-global proves a built UMD
assigns its window global. @ohif/app now devDepends on @ohif/ui.
Adds Track B runtime loading: descriptors in window.config.extensions/modes
are loaded through runtimeExtensionLoader with a strict globalName discriminator
(present => UMD global, absent => ESM default), a fail-closed coreVersionRange
gate (includePrerelease), stylesheet injection, and a window.__ohif audit
record reconciled and surfaced via NotificationService. runtimeShared.ts
exposes the twelve host singletons (react, @OHIF/core, @ohif/ui-next,
@ohif/i18n, @cornerstonejs/*, ...) on window so runtime bundles resolve them,
with a parity test binding the list to pluginExternals.hostSharedPackages.
Shared resolveConfig gains exact-match dedupe aliases so directory and
node_modules plugins resolve one copy of react/@OHIF/core/etc. through both
pipelines. Tailwind content globs extend from pluginConfig entries so
out-of-tree and installed plugins render styled. Adds the directory-plugin
verification recipe.
…oyment templates

pnpm create ohif scaffolds standalone extensions/modes, a full workspace (a
user-owned repo with a committed ohif.config.json manifest and a managed .ohif
viewer harness), or a config-only deployment. A migrate subcommand ports
CLI-era extensions to the current contract. Templates vendor the externals
contract; unit and e2e smoke tests cover scaffold, build, and UMD global.
jbocce added 30 commits October 13, 2026 12:03
… the de-forking guide

- Mode template: extensionDependencies uses the peer range (versions are not checked at present);
  AGENTS.md rule on version checks names coreVersionRange, not the peer range.
- Workspace template: add the `harness` script, so `pnpm run harness upgrade <tag>` works.
- De-forking guide: workspaces need OHIF 3.14+, the scaffolded ohifVersion is the safe default,
  register plugins in ohif.config.json, and upgrade with `pnpm run harness upgrade <tag>`.
'^a|b' anchored only "a", so "b" could match anywhere in ?configUrl=, letting a
hostile URL pass a regex-only allowlist through its query string.
Mode.tsx caught registration errors, but recordRegistrationError only updated
runtime-plugin audit records, so a built-in extension's failure was dropped
without a log or notification. Log it, add an audit record when none exists,
and document the built-in record in the audit reference.
{ type: 'filesystem' } is webpack's form; rspack 2 refuses it, so every
extension's and mode's `pnpm run dev` failed (on master too). Also add the
rspack.dev.js the basic and segmentation modes' dev scripts point at.
…rect peer-range wording in its report and docs
…ript calls to pnpm, and flags leftover yarn and jest scripts
…rs too, optional ones skipped); doctor warns about uninstalled peers; unknown-module notice says how to fix it
…onfig.json and record it as duplicate-of-built-in
…date the CSP guidance for cross-origin plugins, and drop the Track B label

This branch was successfully deployed

1 active (outdated) and 1 inactive (outdated) deployments
unrestricted — 86539735 Deployed Oct 2, 2026 by jbocce via playwright-tests (24.15.0) #5150
fork-pr-approval — cde390bd Deployed Jul 20, 2026 by sedghi via playwright-tests (24.15.0) #4706
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.

5 participants