From twig
Use when routing questions about how Twig's React micro-frontends load and mount — the @twigeducation/async-component runtime, manifest-service discovery, the window.app registry, host/MFE shared-dependency contract via window.* globals, and per-MFE local overrides. Covers host apps (middle-school-react, tsc-react, ts-admin, middle-school-react-monorepo) and the ms-*/ts-*/twig-* MFE repos. Triggers on mentions of AsyncComponent, LoadManifests, window.app, registerComponent, MANIFEST_SERVICE_URL, scriptLoadingCache, async-component, ms-*-mfe, ts-*-mfe, or "why is my MFE blank/stale/not loading".
How this skill is triggered — by the user, by Claude, or both
Slash command
/twig:mfesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill is the mental model, routing guide, and debug checklist for
This skill is the mental model, routing guide, and debug checklist for
how Twig's React micro-frontends load at runtime. It covers three
layers: the @twigeducation/async-component runtime, the host apps
that mount <AsyncComponent>, and the MFE repos that self-register
into window.app. For GraphQL / content staleness questions that
happen inside an MFE, use twig/content-delivery instead — this
skill stops at the loader boundary.
This skill refers to repos by their GitHub name (e.g.
TwigWorld/async-component) rather than a hardcoded local path.
Different users clone to different roots. When you need to read
source from a repo — anything beyond the mental model in this skill
— resolve the path first:
Read ~/.claude/twig/repo-paths.json. Shape:
{
"async-component": "/Users/alex/code/libraries/async-component",
"middle-school-react": "/Users/alex/code/host-apps/middle-school-react"
}
If the file doesn't exist, treat it as {}. If the repo's entry is
present and the directory exists, use it. Don't prompt.
If the entry is missing, use AskUserQuestion to ask for the
repo's local absolute path. Use this shape:
Where is your local clone of TwigWorld/<repo-name>?<repo-name> (truncated to 12 chars)I'll paste the path — user supplies an absolute path via
"Other"Skip — work from docs only — you answer from this skill's
mental model without reading source
Do not offer a "clone it" option; cloning is the user's job.Persist the answer. If the user supplied a path, write it into
~/.claude/twig/repo-paths.json (create the ~/.claude/twig/
directory if needed, pretty-print with 2-space indent, keep keys
sorted). Expand ~ to an absolute path before writing so the file
is portable.
Skip is session-scoped. If the user skipped, remember that for the rest of this conversation and don't re-prompt — but do not write anything to the config file. Next session will ask again, since the user may have cloned the repo in the meantime.
Never run git clone / gh repo clone from this skill.
Repos this skill references: async-component, middle-school-react,
tsc-react, ts-admin, middle-school-react-monorepo, plus the
ms-*-mfe / ts-*-mfe / twig-* MFE repos under mfes/.
┌────────────────────────────────────────────────────────────────┐
│ HOST APP (browser, on boot) │
│ │
│ window.app = new App() ◄── host-defined class │
│ .manifests [] │
│ .registeredComponents [] │
│ .scriptLoadingCache {} │
│ addManifest / updateManifest / registerComponent (methods) │
│ │
│ import './microExport' ◄── attaches React/Redux/Apollo/etc. │
│ to window.* for MFEs to consume │
│ │
│ <LoadManifests │
│ manifestServiceUrl={window.config.MANIFEST_SERVICE_URL}> │
│ │ │
│ │ fetch() → [{ name, manifest }, ...] │
│ │ for each window.config.MFE[name] override: │
│ │ fetch(`${url}/manifest.json`) │
│ ▼ │
│ window.app.manifests populated │
│ │
│ ──── later, on route render ──── │
│ │
│ <AsyncComponent │
│ appName="ms-navbar-mfe" │
│ componentName="Navbar" │
│ errorComponent={AsyncComponentError} │
│ {...restProps} /> │
│ │ │
│ │ (1) findComponent(componentName) │
│ │ → already in registeredComponents? │
│ │ │
│ │ (2) on miss: loadMFE(manifestEntry) │
│ │ await loadScript(vendors.js) │
│ │ await loadScript(main.js) │
│ │ (dedup'd via scriptLoadingCache) │
│ │ │
│ │ (3) MFE's src/index.jsx runs: │
│ │ window.app.registerComponent( │
│ │ 'Navbar', NavbarComponent) │
│ │ │
│ │ (4) findComponent(componentName) → <Component│
│ │ {...restProps} /> │
│ ▼ │
│ MFE renders, reading React/Redux/Apollo from window.* │
└────────────────────────────────────────────────────────────────┘
Not Module Federation. Not SystemJS. Bespoke <script> injection +
window.app registry. Once you see that, every file below makes sense.
@twigeducation/async-component (the runtime)Repo: TwigWorld/async-component (resolve path — see
Resolving repo paths). Small library, ~6
files, React 16.8+/17 peer.
Public API (src/index.jsx):
export { AsyncComponent, LoadManifests } — the two things host
apps actually use.export default asyncComponent(url, componentName, appName) — a
legacy wrapper that ignores url (kept for backwards compat).
Current host code uses the named AsyncComponent directly.What each piece does:
src/AsyncComponent.jsx — props appName, componentName,
errorComponent (defaults to DefaultError), plus ...restProps
that spread through to the loaded component. Calls
useAsyncComponent(appName, componentName); renders <Component {...restProps} /> on success, <ErrorComponent error={error} /> on
failure, null while loading. Contains a console.log hardcoded
for componentName === "StudentSession" — debug left in.
src/LoadManifests.jsx — class component. On componentDidMount:
fetch(manifestServiceUrl) → array of { name, manifest }.
Each entry is pushed via window.app.addManifest(name, manifest).name in window.config.MFE where the value is not
null, additionally fetch('${url}/manifest.json') and call
window.app.updateManifest(name, manifest). This is the
per-MFE override mechanism — null means "use the central
service", a URL means "replace that one entry with the one
served from this origin."render(), so an
ErrorBoundary above catches it).AbortController.abort() cancels in-flight fetches.src/useAsyncComponent/useAsyncComponent.js — React hook.
useEffect (empty deps) calls loadComponent(appName, componentName) and stores the resolved Component or error.
Has a mounted flag to guard setState after unmount.
src/useAsyncComponent/loadComponent.js — the orchestrator:
findComponent(componentName) — fast path, already registered?manifests.find(m => m.appName === appName). Throw
"No manifest found for MFE with app name: …" if missing.scriptLoadingCache[appName] is unset, set it to
loadMFE(manifest). Then await whichever cached promise is
there. Multiple simultaneous <AsyncComponent> mounts of the
same MFE won't trigger multiple script loads.findComponent(componentName) again. Throw "No component found
with component name: …" if the MFE loaded but didn't register
the expected name.src/useAsyncComponent/loadMFE.js — 5 lines. if (manifestEntry.vendors) await loadScript(manifestEntry.vendors.js), then await loadScript(manifestEntry.main.js). Vendors first, main second —
order matters because main references the vendor chunk's UMD
global.
src/useAsyncComponent/loadScript.js — creates a <script src=...>, appends to document.body, resolves on onload,
rejects on onerror. No crossorigin/integrity/nonce attribute
set.
src/useAsyncComponent/findComponent.js — linear scan of
window.app.registeredComponents for component.name === componentName. Case-sensitive. Also has a stray
console.log(window.app.registeredComponents) every call.
v2 vs v3 — The behavioral change between v2.1.3 and v3.0.0 is
commit 3c562c2 "Dedupe MFE loading": concurrent loadMFE calls for
the same appName now share a cached promise via
scriptLoadingCache. The same commit also extracts the old inline
AsyncComponent logic into the useAsyncComponent/ hook tree, but
the public API (AsyncComponent, LoadManifests) is unchanged.
Consumers on v2.x can hit a bug where two <AsyncComponent> mounts
of the same MFE trigger two script loads; the MFE's src/index.jsx
then runs twice and the component registers twice (harmless
lookup-wise, wasteful network-wise, occasionally visible as Apollo
cache double-init). Upgrade is drop-in.
All hosts share the same four-part contract:
window.app = new App() — each host defines its own
App class in src/js/App.js. That class holds
manifests/registeredComponents/scriptLoadingCache plus the
addManifest/updateManifest/registerComponent methods. The
async-component library assumes these exist; it never creates
them. If you grep "where does registerComponent come from" in
async-component and find nothing, this is why — it's host-owned.import './microExport' — attaches React/ReactDom/Redux/Apollo/…
to window.* so MFE webpack externals can resolve.<LoadManifests manifestServiceUrl={window.config.MANIFEST_SERVICE_URL}>
wraps the tree near the root.<AsyncComponent appName=… componentName=… errorComponent=… {...props} /> wherever an MFE mounts.Hosts that implement this:
| Host | async-component | Registry | Notes |
|---|---|---|---|
middle-school-react | v3.0.0 | 23 MFEs (ms-, ts-accounts, twig-) | Canonical |
tsc-react (TwigScience elementary) | v3.0.0 | 15 MFEs (ts-, twig-) | Identical wiring |
middle-school-react-monorepo /packages/middle-school-react | v3.0.0 | Same as middle-school-react | Monorepo is a build convenience; runtime loader is identical |
ts-admin | v2.1.1 | 4 MFEs (ts-integrations, ms-footer-mfe, ms-navbar-mfe, ts-accounts) | Still on v2 — dedupe bug applies |
tst-react does not use async-component — excluded from this
skill.
Key files in middle-school-react (canonical; the others mirror
these):
src/js/App.js — the App class. manifests=[],
registeredComponents=[], scriptLoadingCache={}; plus methods
addManifest(appName, manifest) (pushes),
updateManifest(appName, manifest) (upserts by appName),
registerComponent(componentName, ComponentToRegister) (pushes
{ name, Component }).src/js/index.jsx:39 — window.app = new App().src/js/index.jsx:50 — <LoadManifests manifestServiceUrl={window.config.MANIFEST_SERVICE_URL}> wraps
the route tree.src/js/index.jsx:47 — <Suspense fallback={<p>Loading...</p>}>
is the app-wide loading UI while LoadManifests is in flight.
(AsyncComponent itself renders null during the per-MFE load,
not a spinner — the host supplies the visible loading state.)src/js/microExport.js — attaches react, ReactDom,
ReactRouter, ReactRouterDom, Redux, ReactRedux,
ApolloClient, ApolloClientComponents, ApolloClientHOC,
FormsyReact, ReactInterchange, ReactLazyLoad,
StyledComponents, Unleash, ReactI18Next, AsyncComponent,
ReactCloudinaryImage, OidcClientReact, lodash, ReactGA4,
ReactErrorBoundary to window.*. Casing matters — MFE
webpack externals must match character-for-character (e.g.,
ReactDom not ReactDOM).configuration.js:25 — MANIFEST_SERVICE_URL default
https://api.twigscience.com/svc/mfe-manifest/manifests. Sets
window.config.MANIFEST_SERVICE_URL via the runtime config bundle.configuration.js:26–50 — MFE: { 'ms-navbar-mfe': getEnv('FE_MS_NAVBAR_MFE', null), … } × 23 entries. Each env var
defaults to null. The env var is an override URL, not the
primary URL source — null means "use manifest service", a URL
like http://localhost:6010 means "fetch
http://localhost:6010/manifest.json instead".src/js/components/Navigation/index.jsx:8 — Navbar,
Redux-connected (userInfo, hostApp: 'MS', featureFlags),
{...props} spread to the MFE.src/js/components/Footer/index.jsx:6 — Footer, passes an
unleash={{ clientId, url }} prop so the MFE can init its own
feature-flag client.src/js/pages/StudentDashboard/routes.jsx — wraps
AsyncComponent in a withProductSubscriptions HOC that shows
a loading / unsubscribed error before the MFE can mount.src/js/components/AsyncComponentError/AsyncComponentError.jsx —
the canonical errorComponent passed to every <AsyncComponent>.
It calls Sentry captureException(error) in a useEffect, then
renders a warning icon + i18n-translated "Error loading component"
text. When something blows up in the loader, this is what the
user sees and what shows up in Sentry.Canonical examples: TwigWorld/ms-navbar-mfe,
TwigWorld/ms-student-dashboard-mfe (and ~25 sibling repos under
mfes/). All follow the same pattern.
Entry point (src/index.jsx) is ~3 lines:
import NavbarComponent from './pages/Navbar';
import './i18n';
if (window.app) {
window.app.registerComponent('Navbar', NavbarComponent);
}
No default export is consumed by the loader. No mount/unmount
hooks. The entire contract is: imperatively push a { name, Component } into window.app.registeredComponents if the host
provided window.app. The if (window.app) guard makes the
bundle safe to load outside a host (e.g., the MFE's own
dev-server landing page).
Build (webpack.config.js, webpack.prod.config.js):
Webpack 5, UMD library output (output.library: 'msNavbarMfe'),
filenames [name].bundle.[chunkhash].js. Two chunks: main (the
MFE itself) and vendors (auto-split for everything from
node_modules, via splitChunks.cacheGroups.commons). Dev-server
publicPath is a hardcoded localhost port per MFE
(http://localhost:6010/ for navbar; every MFE uses a distinct
port).
Externals (webpack.config.js) — every "heavy" dep the host
also has is marked external and assumed to exist on window.*:
react, react-dom (→ ReactDom), react-router,
react-router-dom, redux, react-redux,
@apollo/client (→ ApolloClient),
@apollo/client/react/components,
@apollo/client/react/hoc, styled-components,
react-i18next, @twigeducation/react-interchange, sometimes
formsy-react. The external value is the window attribute
name; matching this to what the host's microExport.js attaches
is the whole ballgame.
Manifest (manifest.json) generated at build time by
assets-webpack-plugin (configured in webpack.prod.config.js).
Shape (from an actual navbar build):
{
"main": { "js": "<publicPath>/main.bundle.<hash>.js" },
"vendors": { "js": "<publicPath>/vendors.bundle.<hash>.js" },
"metadata": { "componentName": "MyComponent" }
}
metadata.componentName is informational; AsyncComponent
ignores it (the host passes componentName explicitly).
Deploy (Makefile, Jenkinsfile, Dockerfile): Docker image
→ ECR 817276302724.dkr.ecr.eu-west-1.amazonaws.com/ngss/<mfe>.
When ENABLE_S3=true, webpack-s3-plugin uploads dist/ to S3
fronted by CloudFront with a 1-year cache
(CacheControl: 'max-age=31104000'), and rewrites publicPath to
${AWS_CLOUDFRONT_URL}/${AWS_LOCATION}/ so manifest URLs are CDN
absolute. Post-deploy, bin/load_manifest.sh POSTs the built
manifest.json to $MANIFESTS_ENDPOINT with a bearer
$ADMIN_JWT. That POST is what makes the new hash visible to
hosts on their next LoadManifests fetch — until it runs, the
central service still points at the previous hash.
Dev — yarn start runs webpack-dev-server on the MFE's port,
serves /manifest.json locally, rebuilds on save. To make a host
load it, run the host with yarn start:prod (which wraps yarn start with prod auth/graphql endpoints; plain yarn start is
rarely what you want locally) and set the host's FE_<MFE_NAME>
env var to http://localhost:<port> (see Common troubleshooting
moves). Never use npm in these repos — they're
yarn-managed, and npm will ignore the package.json scripts'
yarn-specific composition (e.g., start:prod → yarn start).
Use this to pick the right file/repo for a given question.
| If the question is about… | Start in… |
|---|---|
What <AsyncComponent> does on render | async-component/src/AsyncComponent.jsx + useAsyncComponent/useAsyncComponent.js |
| "No component found with component name: …" / "No manifest found for MFE with app name: …" | async-component/src/useAsyncComponent/loadComponent.js — trace which throw fires |
| How manifests are fetched at boot | async-component/src/LoadManifests.jsx + host src/js/index.jsx + configuration.js:MANIFEST_SERVICE_URL |
Per-MFE local override (pointing host at localhost:6010) | Host configuration.js MFE map (FE_<MFE_NAME> env var) → populates window.config.MFE[name] → LoadManifests.fetchManifestOverride |
| Dedupe / "why is my MFE fetched twice" | async-component/src/useAsyncComponent/loadComponent.js scriptLoadingCache (v3 only; v2 has the bug) |
window.app / registerComponent / manifests definition | Host's src/js/App.js. Not in async-component — library assumes it. |
window.* shared libs (React, Apollo, …) | Host's src/js/microExport.js (producer) + MFE's webpack.config.js:externals (consumer). Names must match byte-for-byte. |
| MFE's root component / what it registers | MFE's src/index.jsx — one window.app.registerComponent(name, Component) call |
| MFE build output shape / hash filenames / vendor split | MFE's webpack.config.js + webpack.prod.config.js (assets-webpack-plugin, splitChunks.cacheGroups.commons) |
| Deploy pipeline (ECR, S3, CloudFront, manifest publish) | MFE's Dockerfile, Makefile, Jenkinsfile, bin/load_manifest.sh |
| Error UI when an MFE fails to load | Host's src/js/components/AsyncComponentError/AsyncComponentError.jsx (Sentry captureException + warning icon) |
| App-wide "Loading…" placeholder (only while manifests are fetching at boot) | Host src/js/index.jsx <Suspense fallback={<p>Loading...</p>}> |
| Shared auth / user / subscriptions / i18n inside an MFE | Host-level providers (Redux, Apollo, OIDC, Unleash, ThemeProvider, I18n) in host src/js/index.jsx. MFE accesses these via react-redux / @apollo/client → window.ReactRedux / window.ApolloClient. |
| Differences between async-component v2 and v3 | Commit 3c562c2 in async-component — dedupe cache. API identical. |
| GraphQL / content staleness inside an MFE | Not this skill. Use twig/content-delivery. |
Walk the layers in order. The host's browser devtools is almost always the fastest place to start — the runtime is entirely client-side.
MFE tile is blank / stuck. AsyncComponent renders null
until the component resolves, so "blank" usually means the load is
in flight or a silent failure. Check in order:
MANIFEST_SERVICE_URL return 200 with an
entry for the appName?window.app.manifests.find(m => m.appName === '<appName>') — is it there, and do the URLs look right?window.config.MFE['<appName>'] override it to a URL that's
down? (e.g., stale localhost override from a .env.local.)vendors.bundle.*.js and main.bundle.*.js fetch
(Network tab)? 404 on either aborts the load and triggers
errorComponent.window.app.registeredComponents.map(c => c.name) — did the
MFE register the exact componentName the host passed in?
Case-sensitive. "Navbar" ≠ "NavBar".AsyncComponentError renders instead of the MFE. Check
Sentry; the error prop wraps whatever loadMFE / loadScript
threw. Common causes, in descending order of frequency:
<script onerror> fires or the script runs but crashes
before registerComponent. Look for the file in Network,
open it, check for parse errors.throw "No component found with component name: …" in
loadComponent.js. Grep the MFE's src/index.jsx for what it
actually registers."Works in prod, blank locally." The MFE's local
webpack-dev-server isn't running, or the host's
FE_<SCREAMING_SNAKE_CASE> env var isn't set. Match against the
host's configuration.js MFE map — env-var names are all
FE_<NAME> where <NAME> is the MFE name uppercased with dashes
→ underscores (ms-navbar-mfe → FE_MS_NAVBAR_MFE). Common
gotcha: setting the override to http://localhost:6010 when the
MFE is actually serving on a different port — check the MFE's
webpack.config.js output.publicPath for the canonical port.
"MFE can't find React / Redux / Apollo." The MFE's webpack
externals expect a host global. Triangulate three files:
webpack.config.js:externals — what name is the MFE
reaching for?src/js/microExport.js — is that name attached to
window?window.<name> — is it actually there? If
microExport.js has it but window doesn't, the import
might have failed silently (e.g., a peer-dep mismatch after a
package bump).
Casing mismatches (ReactDom vs ReactDOM,
ApolloClient vs ApolloClient) are the recurring bug —
externals compare by exact string."MFE mounted twice / duplicate network requests." v2→v3
dedupe bug. Hosts on async-component@2.x (today: ts-admin)
can race two <AsyncComponent> mounts of the same appName and
trigger two loadMFE calls. Either upgrade the host to v3 or
memoize/hoist the AsyncComponent usage so only one instance
exists during boot. The registered component list will contain
two entries with the same name — findComponent returns the
first one, so behavior looks correct but network waste and
Apollo cache double-init can surface weirdness.
"Stale MFE after a deploy." Bundle filenames are
content-hashed ([chunkhash]), so the browser never serves a
stale bundle directly. Staleness = stale manifest. Two
checks:
bin/load_manifest.sh actually POST the new
manifest? Look at the Jenkins job log — it's a shell curl
that checks for "createdAt" in the response. If that step
didn't run (often gated by a deploy env condition), the
central manifest service still holds the old hash.window.app.manifests in devtools — does the hash match what
you just deployed? If not, either the POST didn't happen or
LoadManifests cached a stale fetch (refresh the page)."MFE works in the monorepo but not as a standalone repo" (or
vice versa). middle-school-react-monorepo builds its MFEs
with possibly different externals / webpack config than the
standalone MFE repo publishes. Check which webpack config
actually ran (monorepo root vs packages/<mfe>/webpack.*), and
diff the externals lists — mismatched externals mean the MFE
bundle expects window.<X> that the host doesn't attach.
"MFE loads but a prop is undefined." Host <AsyncComponent {...props} /> spreads restProps, but only the props the host
actually passes at the call site. Missing context (e.g.,
userInfo) usually means the host wrapper isn't
Redux-connect'd at that call site. Compare against a known-good
usage like src/js/components/Navigation/index.jsx in
middle-school-react.
Open these files directly when the mental model above isn't enough — every path is relative to the resolved repo root.
async-component (the runtime, all ~6 files):
src/index.jsxsrc/AsyncComponent.jsxsrc/LoadManifests.jsxsrc/useAsyncComponent/useAsyncComponent.jssrc/useAsyncComponent/loadComponent.jssrc/useAsyncComponent/loadMFE.jssrc/useAsyncComponent/loadScript.jssrc/useAsyncComponent/findComponent.jsHost (pick the relevant one — files have the same names across all four hosts):
src/js/index.jsx — root wiring (window.app = new App(),
<LoadManifests>, <Suspense>)src/js/App.js — the App class (host-owned window.app)src/js/microExport.js — window.* shared-dep contractconfiguration.js — MANIFEST_SERVICE_URL + MFE override mapsrc/js/components/AsyncComponentError/AsyncComponentError.jsx<AsyncComponent ...> usage site (search AsyncComponent in
src/js/)MFE (pick the relevant one):
src/index.jsx — registerComponent callwebpack.config.js — externals + dev publicPathwebpack.prod.config.js — manifest + S3 + hashingbin/load_manifest.sh — POSTs the manifest after deploymanifest.json (generated, committed in some repos; see for shape)twig/content-delivery — MFEs consume GraphQL from the
federated subgraphs documented there (twig-graph for elementary,
content-gateway-service for middle-school). If an MFE renders but
its data is wrong/stale/missing, the loader is innocent — walk
into content-delivery.twig/tocs-api — upstream of content-delivery. Usually not
relevant to MFE loader questions.npx claudepluginhub imaginelearning/dp-claude-plugin --plugin twigCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.