MemberJunction / MemberJunction/MJ
6.x: lazy-load Open App client packages as their own chunk instead of forcing them into the main bundle
- Dominant language
- TSQL
- Stars
- 29
- Forks
- 6
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 323
Description
Follow-up from #3282 and #3337, opened at @cadam11's direction:
> merge this now. Madhav, aim the 6.x issue you offered to open at the lazy path rather than dist scanning plus an export contract. I.e. stop forcing Open App client packages into the main bundle at all, and instead have each app declare its registrations at publish time so the client can lazy-load the app as its own chunk on first use, through the lazy-loading machinery Explorer already has.
## Why the current shape is the wrong one to keep extending
Both fixes proposed on #3282 — the referenced-namespace + `globalThis` anchor that shipped, and the `--scan-dist` + `public-api.ts` export contract that was proposed instead — are the same workaround wearing different hats. The root problem is **registration by import-time side effect**: the bundler is correctly identifying dead code, and we keep inventing ways to make it evaluate the module anyway.
The export-contract path additionally doesn't hold against today's packages. @jordanfanapour's and my numbers from that thread:
- `@mj-biz-apps/committees-ng@1.0.0`: **32** `@RegisterClass`-decorated classes in `dist/`, **19 absent** from the recursive `export*` / `export {}` walk of `public-api.d.ts`.
- `packages/Angular/Explorer/core-entity-forms` on `next`: **393 of 414** registered classes absent from the same walk.
So this is not an Open App authoring bug to fix in one repo — our own core package has the same shape.
## The direction
Machinery that skips the fight already exists and is already wired in Explorer:
- `ClassFactory.RegisterLazyLoader(loader)` ([MJGlobal/src/ClassFactory.ts:136](packages/MJGlobal/src/ClassFactory.ts#L136)) — called from `GetRegistrationAsync` / `CreateInstanceAsync` when a key misses synchronously.
- `LazyModuleRegistry` ([explorer-core/src/lib/services/lazy-module-registry.ts](packages/Angular/Explorer/explorer-core/src/lib/services/lazy-module-registry.ts)) — maps `BaseClassName::Key` → `() => import(...)`, dedupes concurrent loads, publishes a snapshot for the dev-tools inspector.
- `LAZY_FEATURE_CONFIG`, generated by `mj codegen manifest --lazy-config`, registered via `lazyRegistry.RegisterBulk(...)` in MJExplorer's `app.module.ts`.
A dynamic `import()` is a real reference, so nothing gets shaken out; evaluating the chunk runs every `@RegisterClass` in it regardless of what `public-api.ts` exports; and each app becomes its own chunk instead of another tenant in an already-large main bundle.
**The missing piece is the key→package map, and the publisher should ship it.** `mj app publish` has the app's source available, so it can scan there and emit a small manifest alongside the package. No dist scanning at install time, no export contract, no `--scan-dist` flag.
## Sketch
1. `mj app publish` scans the app source for `@RegisterClass(Base, 'Key')` and emits a registration manifest into the published package (compound keys only — no code, no imports).
2. `mj app install` reads that manifest and records the app's key set in `mj.config` alongside the existing `dynamicPackages.client` entry.
3. `mj codegen manifest` emits those keys into `LAZY_FEATURE_CONFIG` pointing at a per-app `() => import('')` loader, instead of emitting the eager bootstrap block.
4. The eager Open App client bootstrap block shrinks to only the keys that must be eager (below), or goes away entirely if there are none.
## Two caveats that must be designed for, not discovered
Both from @cadam11's comment:
1. **Sync `CreateInstance` call sites cannot trigger a lazy load.** Only `GetRegistrationAsync` / `CreateInstanceAsync` consult `_lazyLoaders`; the sync path returns the base-class fallback. There are ~9 sync `CreateInstance` sites vs ~12 async ones in `packages/Angular` + `packages/MJExplorer` today, so this is a real audit, not a formality — each sync site either has to move to the async API or be documented as unable to resolve a lazily-registered key.
2. **An app overriding an existing key must never miss.** If an app registers over a key that already resolves, a lazy miss silently returns the *base* implementation rather than failing — the override just doesn't apply, with no error. Those keys need eager registration, which means the publish-time manifest has to distinguish "new key" from "override of an existing key".
## Scope note
The durable companion fix — the Open App scaffold emitting `"sideEffects": true` for generated `-ng` packages, matching what MJ's own self-registering packages already do — lives in the app-authoring repo, not here. Worth doing regardless of this issue's outcome; it is what makes a bare import correct in the first place.
Related: #3282 (shipped workaround), #3337 (follow-up hardening + docs).
Contributor guide
Research direction
Start with packages/MJGlobal/src/ClassFactory.ts, explorer-core/src/lib/services/lazy-module-registry.ts, and packages/Angular/MJExplorer/app.module.ts to trace the existing lazy-loading configuration. Then inspect the mj app publish/install and codegen manifest paths described in the issue, including the eager bootstrap. Audit the sync CreateInstance sites and async paths for the stated caveats. Done means published manifests drive per-app lazy loaders while override keys and eager-only cases are handled.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- angular, typescript
- Domain
- build-system, frontend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100