Skip to content

Custom Framework Extensibility

External frameworks extend rspfx without modifying the CLI or built-in packages.

See frameworks for built-in frameworks, internal-api for package surfaces, architecture for the pipeline, commands for CLI flags.

Built-in presets cover React, Vue, Svelte, Solid, Preact and vanilla; Angular, Lit, Qwik, Astro, Ember, Stencil, Alpine, Mithril and Inferno are implemented as custom frameworks via the FrameworkPreset contract below.

Contract

A framework provides two parts that the pipeline consumes separately:

PartImportFact home
FrameworkPresetimport type { FrameworkPreset } from '@mbsks/rspfx-plugin-api'packages/plugin-api/src/types.ts:29
BaseWebPart subclassimport { BaseWebPart } from '@mbsks/rspfx-core/webpart'packages/core/src/base-web-part.ts:10

FrameworkId is the open string union defined in internal-api (packages/core/src/config.ts:4); custom ids use the (string & {}) branch as FrameworkPreset<string>.

The preset contributes compiler configuration (rules, swc, plugins, resolve, define); the web part subclass mounts at runtime via renderInto / disposeFrom / getComponentProps.

Builtin packages packages/framework-<name> split the contract into two entry points (preset at @mbsks/rspfx-framework-<fw>, web part class at @mbsks/rspfx-framework-<fw>/webpart); external frameworks may use the same split or colocate exports behind separate import paths.

Preset

Define src/framework/my-preset.ts exporting a preset with a custom name:

ts
import type { FrameworkPreset } from '@mbsks/rspfx-plugin-api';
import ReactRefreshRspackPlugin from '@rspack/plugin-react-refresh';

export const myPreset: FrameworkPreset<'my-framework'> = {
  name: 'my-framework',
  rspack({ fastRefresh }) {
    return {
      rules: [{ test: /\.tsx?$/, use: { loader: 'builtin:swc-loader', options: { jsc: { parser: { syntax: 'typescript', jsx: true } } } } }],
      swc: { jsc: { transform: { react: { runtime: 'automatic', development: fastRefresh } } } },
      plugins: fastRefresh ? [new ReactRefreshRspackPlugin()] : [],
      resolve: { extensions: ['.ts', '.tsx', '.js'] }
    };
  },
  vite({ fastRefresh }) {
    return { plugins: fastRefresh ? [myVitePlugin()] : [], esbuild: { jsx: 'automatic' } };
  },
  rsbuild({ fastRefresh }) {
    return { rules: [], plugins: fastRefresh ? [new ReactRefreshRspackPlugin()] : [], resolve: { extensions: ['.ts', '.tsx'] } };
  },
  /** @deprecated use rspack() */
  contributions(opts) { return this.rspack(opts); }
};

function myVitePlugin() { return { name: 'my-framework-hmr' }; }
MethodReturn typeRequiredFallback
rspack({ fastRefresh })FrameworkRspackContributions (packages/plugin-api/src/types.ts:3)yes
contributions({ fastRefresh })FrameworkRspackContributions (packages/plugin-api/src/types.ts:3)nodeprecated alias for rspack()
vite({ fastRefresh })FrameworkViteContributions (packages/plugin-api/src/types.ts:12)norspfxVite (packages/plugin/src/vite.ts:299) warns and runs without Vite contributions
rsbuild({ fastRefresh })FrameworkRsbuildContributions (packages/plugin-api/src/types.ts:21)norspfxRsbuild (packages/plugin/src/rsbuild.ts:354) falls back to rspack() minus swc

fastRefresh reflects dev.fastRefresh (packages/core/src/config.ts:138) or RSPFX_FAST_REFRESH=1 / rspfx dev --refresh (apps/cli/src/commands/dev.ts:80) and is merged by @mbsks/rspfx-compiler-rspack, rspfxVite, and rspfxRsbuild.

Bare loader strings in rules[].use that are not builtin: are resolved against the framework module via resolveContributionLoaders (packages/dev-runtime/src/project.ts:759).

Web part class

Define a browser-only base class extending BaseWebPart:

ts
import { BaseWebPart } from '@mbsks/rspfx-core/webpart';
import { createRoot, type Root } from 'react-dom/client';
import type { ReactElement } from 'react';

const roots = new WeakMap<HTMLElement, Root>();

export abstract class MyWebPart<TProps extends Record<string, unknown>> extends BaseWebPart<TProps> {
  protected abstract renderComponent(props: TProps): ReactElement;
  protected renderInto(root: HTMLElement): void {
    const r = roots.get(root) ?? createRoot(root);
    roots.set(root, r);
    r.render(this.renderComponent(this.getComponentProps()));
  }
  protected disposeFrom(root: HTMLElement): void { roots.get(root)?.unmount(); roots.delete(root); }
  protected getComponentProps(): TProps { return this.properties; }
}
HookContract
getComponentProps(): TPropsDerive props from this.properties
renderInto(root: HTMLElement)Mount into root (this.domElement)
disposeFrom(root: HTMLElement)Tear down effects and listeners

BaseWebPart.render() calls renderInto(this.domElement); onDispose() calls disposeFrom(this.domElement).

Per-root WeakMap follows the builtin pattern; dispose-then-recreate applies to Solid/Vue/Svelte-style frameworks while React/Preact re-render in place (see frameworks).

Keep the web part class in a browser-only path (e.g., src/framework/my-webpart.ts or a separate package /webpart subpath) and do not import it from the Node-safe preset entry.

Registration

Register the preset in the bundler config that jiti loads (apps/cli/src/config.ts:69):

ts
import { RspfxPlugin } from '@mbsks/rspfx-plugin';
import { definePlugin, registerPlugin } from '@mbsks/rspfx-plugin-api';
import { myPreset } from './src/framework/my-preset.js';

registerPlugin(definePlugin({ name: 'my-framework-ext', frameworkPreset: myPreset }));

export default {
  plugins: [new RspfxPlugin({ name: 'my-app', framework: 'my-framework' as const, spfxVersion: '1.23' })]
};
APIPackageFile
definePlugin, registerPlugin, getPlugins@mbsks/rspfx-plugin-apipackages/plugin-api/src/registry.ts:5

registerPlugin writes to an in-memory registry read by getPlugins() during build and dev.

registerPlugin must execute at the top level before RspfxPlugin resolves the framework; the config runs synchronously via jiti.

RspfxConfig.framework (packages/core/src/config.ts:54) accepts any string; use as const for the custom literal.

No CLI change is required.

Bundler variants

ConfigPluginPreset method
rspack.config.tsRspfxPlugin (@mbsks/rspfx-plugin)rspack() (alias contributions())
vite.config.tsrspfxVite (@mbsks/rspfx-plugin)vite() then resolveContributionLoaders
rsbuild.config.tsrspfxRsbuild (@mbsks/rspfx-plugin)rsbuild() then resolveContributionLoaders
ts
// vite.config.ts
import { rspfxVite } from '@mbsks/rspfx-plugin';
import { definePlugin, registerPlugin } from '@mbsks/rspfx-plugin-api';
import { myPreset } from './src/framework/my-preset.js';
registerPlugin(definePlugin({ name: 'my-framework-ext', frameworkPreset: myPreset }));
export default { plugins: [rspfxVite({ name: 'my-app', framework: 'my-framework' as const })] };
ts
// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { rspfxRsbuild } from '@mbsks/rspfx-plugin';
import { definePlugin, registerPlugin } from '@mbsks/rspfx-plugin-api';
import { myPreset } from './src/framework/my-preset.js';
registerPlugin(definePlugin({ name: 'my-framework-ext', frameworkPreset: myPreset }));
export default defineConfig({ plugins: [rspfxRsbuild({ name: 'my-app', framework: 'my-framework' as const })] });

Hot reload

Gate HMR plugins and compiler flags on fastRefresh so prod builds stay clean; the pipeline passes fastRefresh: true only for rspfx dev --refresh or dev.fastRefresh: true (packages/dev-runtime/src/serve.ts:167, packages/plugin/src/vite.ts:545, packages/plugin/src/rsbuild.ts:445).

BundlerPreset methodWhat to return when fastRefresh: trueBuiltin reference
rspackrspack({ fastRefresh })plugins: [new ReactRefreshRspackPlugin()] and swc.jsc.transform.react.development: truepackages/framework-react/src/index.ts:23
rspack (Preact)rspack({ fastRefresh })plugins: [new PreactRefreshRspackPlugin()] via @rspack/plugin-preact-refreshpackages/framework-preact/src/index.ts:28
rspack (Svelte)rspack({ fastRefresh })svelte-loader with { hotReload: true, compilerOptions: { dev: true } }packages/framework-svelte/src/index.ts:51
rspack (Solid)rspack({ fastRefresh })babel-loader with solid-refresh/babel plugin or builtin:swc-loader with rspackExperiments.swcPluginspackages/framework-solid/src/index.ts:33
rspack (Vue)rspack()vue-loader + VueLoaderPlugin (HMR is always on; ignore fastRefresh)packages/framework-vue/src/index.ts:19
vitevite({ fastRefresh })Vite plugin: @vitejs/plugin-react / @prefresh/vite / @sveltejs/vite-plugin-svelte { hot: true } / @vitejs/plugin-vuepackages/framework-react/src/index.ts:39, packages/framework-preact/src/index.ts:56, packages/framework-svelte/src/index.ts:78
rsbuildrsbuild({ fastRefresh })Same as rspack (babel-loader + refresh plugins); missing rsbuild() falls back to rspack() minus swcpackages/framework-react/src/index.ts:46, packages/plugin/src/rsbuild.ts:445

Missing peers do not break the build; @mbsks/rspfx-compiler-rspack stubs @rspack/plugin-react-refresh, @rspack/plugin-preact-refresh, vue-loader, svelte-loader via BUILD_TIME_ALIASES (packages/compiler-rspack/src/config.ts:16) and logs a warning with fallback to full reload (see fast-refresh.md).

RefreshRuntime (packages/dev-runtime/src/refresh.ts:21) is framework-agnostic; it suppresses the full reload when fastRefresh is on and the framework is not vanilla, otherwise the dev server falls back to reload (packages/dev-runtime/src/serve.ts:335).

Source maps

Presets do not set devtool or sourcemap; the pipeline owns it.

createRspackConfig sets devtool: 'source-map' for dev/serve and devtool: hidden-source-map only when build.sourcemap: true in production (packages/compiler-rspack/src/config.ts:143); rspfxVite (packages/plugin/src/vite.ts:648) and rspfxRsbuild (packages/plugin/src/rsbuild.ts:436) mirror the same rule.

Enable prod maps with build.sourcemap: true in the plugin config (packages/core/src/config.ts:18) or rspfx build --sourcemap (apps/cli/src/commands/build.ts:223); dev maps need no config.

Resolution flow

loadFrameworkPreset (packages/dev-runtime/src/project.ts:737) resolves in order: createRequire(projectRoot/package.json) for @mbsks/rspfx-framework-<id> (records __rspfxModuleUrl for loader resolution), then in-memory registry from registerPlugin, then a no-op preset with a warning.

resolveContributionLoaders (packages/dev-runtime/src/project.ts:759) rewrites rules[].use loader and Babel preset/plugin strings via createRequire(frameworkModuleUrl).resolve; builtin:swc-loader is unchanged.

Angular, Lit, Qwik, Astro, Ember, Stencil, Alpine, Mithril, Inferno

This guide is the entry point for Angular, Lit, Qwik, Astro, Ember, Stencil, Alpine, Mithril and Inferno. Implement FrameworkPreset and a BaseWebPart subclass per Contract, Preset and Web part class above, then register with definePlugin/registerPlugin. See frameworks.md for the comparison table.

Scaffolding and limits

  • packages/templates and rspfx new remain builtin-only.

  • To use a custom framework in a new project: rspfx new my-app --framework vanilla --yes, add src/framework/my-preset.ts and src/framework/my-webpart.ts, register the preset in the bundler config, update src/webparts/<name>/<name>WebPart.ts to extend MyWebPart.

  • Custom ids share the open FrameworkId branch; no validation beyond string identity.

  • rspfx new --help, rspfx doctor, and CLI prompts cover builtin ids only; rspfx doctor reports the framework package as missing when the preset comes from the registry, but the build succeeds via the registry fallback.

  • resolveContributionLoaders rewrites only rules[].use and Babel strings; swc, define, and resolve are merged without path rewriting.

Released under the MIT License.