Migration case study: PnP Modern Search
Play-by-play of migrating PnP Modern Search (v4.23.3) from the official SPFx toolchain to RSPFx — the exercise behind examples/modern-search and migrating-from-gulp-heft.md.
Why this project
One of the largest open-source SPFx solutions — ≈42k lines, 4 web parts, 178 TS files, 24 SCSS modules — and a stress test:
- React 17, Fluent UI 8, Graph Toolkit, PnPjs, Handlebars, Adaptive Cards, react-ace, dayjs, markdown-it.
- 14 locales via
config.jsonlocalizedResources. - Lazy chunks (
React.lazy+webpackChunkName). - Custom
spfx-customize-webpack.js(handlebars min,process/browser,adaptive-expressions, moment exclusion). - 16 Graph scopes in
webApiPermissionRequests. pkg:@fluentui/...SCSS import (sass-loader ≥16.5 syntax).
Web parts only — no extensions or libraries — which is RSPFx's core surface (extensions/libraries are also supported now, but this case predates them).
The migration
Clone upstream at
search-partsv4.23.3 (SPFx 1.23.0, Heft rig).Run migration — mechanical steps:
- Drop 25 toolchain devDependencies (Heft, rig, webpack, loaders, eslint, polyfills).
- Rewrite
config/config.jsonentrypoints./lib/...WebPart.js→./src/...WebPart.ts; rename bundle keys to folder names. - Rewrite the one
pkg:SCSS import to a relativenode_modulespath. - Delete rig/sass/typescript/customize-webpack configs.
- Write
rspack.config.tswithRspfxPlugin+ plaintsconfig.json.
bun install— ~52s from warm cache.rspfx build— handles:*.htmltemplate imports asasset/source(raw string).- Localized strings (
SearchResultsWebPartStrings,CommonStrings, …) vialocalizedResources— default localeen-us, includingnode_modulesresources from@pnp/spfx-controls-react.
rspfx package— valid.sppkgon first try:- 213 entries;
AppManifest.xmlwith 16RequestedWebApiPermissionentries. - 4
WebPart_<id>.xmlelements. ClientSideAssets/with bundles and chunks, manifests rewritten toHTTPS://SPCLIENTSIDEASSETLIBRARY/.
- 213 entries;
rspfx dev— workbench athttps://localhost:4321served 4 web parts; bundles load over HTTPS with AMDdefine('<id>_<version>', …)header.
Numbers
| Metric | Value |
|---|---|
| Source | 178 TS/TSX, 24 SCSS, ~3.0 MB, ≈42k lines |
| Toolchain devDependencies removed | 25 |
src/ files edited | 0 |
| Production build (cold, minified) | ~2.1 s |
.sppkg size | 2.7 MiB (213 entries) |
| Clone to green build | ~1 hour (including two toolchain fixes) |
Gaps surfaced
| Gap | Resolution |
|---|---|
| Multi-locale switching | Compiled to per-locale AMD modules (dist/<name>_<locale>.js) with localizedPath entries; en-us fallback, ?locale= preview — no manual work |
pkg: SCSS import | One-line rewrite (bundled sass-loader <16.5) |
| Bundle-name constraint | Bundle keys must equal web part folder names — mechanical rename in config.json |
spfx-customize-webpack.js | All 5 aliases unnecessary under Rspack; custom behavior via RspfxPlugin in rspack.config.ts or compilerHooks |
What did not need changing
src/webparts/**— zero edits.config/package-solution.json,config/serve.json,config/write-manifests.json— read as-is.sharepoint/assets,teams/manifests — untouched.@microsoft/sp-*— kept at upstream versions;sp-*IDs harvested fromnode_modules(stable across 1.20–1.23).
Comparison vs official
| Area | Official (Heft) | RSPFx |
|---|---|---|
| Full production build | Minutes | ~2 s |
| Config files | Heft rig + webpack customizer | One rspack.config.ts (or Vite/Rsbuild, or zero-config) |
SCSS pkg: imports | Supported via sass-loader ≥16.5 | Relative path rewrite (one line) |
| Packaged artifact | Same .sppkg ZIP layout | Same — byte-compatible |
Tip: Replay with
examples/modern-search(migrated copy in this repo) or run the script against a fresh clone.
Replay
git clone https://github.com/microsoft-search/pnp-modern-search.git
cd pnp-modern-search/search-parts
node <rspfx-repo>/scripts/migrate-to-rspfx.mjs .
bun install
rspfx build && rspfx packageOr use examples/modern-search directly — same source, RSPFx toolchain.
Takeaway
For web-part-only solutions with standard config/ layout, migration is mechanical — no src/ edits, one SCSS line, one config.json rename — and build times drop an order of magnitude.
The honest limits are in why-not-to-migrate.md.