Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ deploy-options.json
# LWC VSCode autocomplete
jsconfig.json

#dist
dist/

# TypeScript incremental build cache
*.tsbuildinfo

# LWC Jest coverage reports
coverage/

Expand Down
61 changes: 22 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,27 +40,23 @@ Today this mode lives in [`force-app/main/react-recipes/uiBundles/reactRecipes/`

### Externally Hosted

The framework app runs on your own infrastructure (Vercel, AWS, anywhere) and is embedded into a Salesforce Lightning page via `lwc-shell`. A postMessage bridge proxies data, events, and GraphQL between the two sides.
The framework app runs on your own infrastructure (Vercel, AWS, anywhere) and is embedded into a Salesforce Lightning page via the standard `<lightning-embedding>` base component. The Platform SDK is the contract on the guest side; the sf-embedding protocol is the wire format underneath.

```mermaid
graph LR
A[External Framework App<br/>mfe-app/] -->|iframe src| B[lwc-shell]
A[Guest recipes<br/>reactRecipes /embedding/*] -->|iframe src| B[lightning-embedding]
B -->|embedded in| C[LWC Host Component]
C -->|deployed to| D[Salesforce Org]
B <-->|postMessage bridge| A
D -->|updateData / events| B
A <-->|"@salesforce/platform-sdk"| B
```

**Use when:** you already have an externally hosted app, need your own build/release cadence, or want to reuse the same app across Salesforce and non-Salesforce surfaces.

Today this mode lives in [`mfe-app/`](mfe-app/) (the external React app) plus [`force-app/main/default/lwc/mfe*`](force-app/main/default/lwc/) (the LWC host components).
Today this mode lives in the React Recipes bundle under [`force-app/main/react-recipes/uiBundles/reactRecipes/src/mfe/`](force-app/main/react-recipes/uiBundles/reactRecipes/src/mfe/) (the guest recipes, served on `/embedding/*`) plus [`force-app/main/default/lwc/mfe*`](force-app/main/default/lwc/) (the LWC host components).

### How the framework app talks to Salesforce
### The Platform SDK: one API, both modes

The two hosting modes use different APIs because they run in different security contexts:

- **Salesforce-hosted** recipes call native Lightning modules directly — `lightning/uiRecordApi`, `lightning/navigation`, `lightning/graphql`, `@salesforce/apex` — same as any LWC.
- **Externally hosted** recipes use the postMessage bridge ([`@salesforce/experimental-mfe-bridge`](https://www.npmjs.com/package/@salesforce/experimental-mfe-bridge)) to talk to the host LWC, which then calls Lightning modules on the MFE's behalf. The bridge surface (`bridge.isConnected()`, `bridge.dispatchEvent()`, `bridge.addEventListener('data', …)`, etc.) is the contract between the embedded app and the host shell.
Both hosting modes use the **Platform SDK** as the unifying contract — it's how the framework app reads Salesforce data, dispatches events to the host, and stays in sync with org-level theme tokens. The SDK surface is the same whether the app is Salesforce-hosted or externally hosted; only the transport underneath differs. Learning the SDK is the portable skill this repo teaches.

## Table of Contents

Expand Down Expand Up @@ -244,56 +240,43 @@ These recipes run as a Salesforce UI Bundle served directly from the org. Today

## Install & Run Externally Hosted Recipes

These recipes run an external framework app on your own server and embed it into Salesforce via `lwc-shell`. In development, "externally hosted" means `localhost:4300`; in production you would point the LWC host at your deployed URL. Today the only implementation is React; Vue and Angular are planned.
These recipes serve the guest app on your own server and embed it into Salesforce via the standard `<lightning-embedding>` base component. The guest recipes live inside the React Recipes bundle (`src/mfe/recipes/`) and are served on the `/embedding/*` routes of its dev server. In development, "externally hosted" means `localhost:5173`; in production you would point the LWC host at your deployed URL. Today the only implementation is React; Vue and Angular are planned.

> **Before you start:** complete the [Scratch Org](#setting-up-a-scratch-org) or [Sandbox](#setting-up-a-sandbox) setup first. Those steps deploy the shared metadata (objects, classes, **CSP Trusted Site for `localhost:4300`**), assign the `recipes` permission set, and import the sample Contact data the recipes display. Without them the iframe is blocked or shows empty data.
> **Note:** The CSP trusted site for `localhost:5173` is included in the shared metadata deployed in the org setup steps above. No additional metadata deploy is needed.

1. Install dependencies for the external app:
1. Install dependencies and start the React Recipes dev server:

```bash
cd mfe-app
cd force-app/main/react-recipes/uiBundles/reactRecipes
npm install
```

1. Start the external app's dev server:

```bash
npm run dev
```

The app starts at `http://localhost:4300`. Keep this running while using the externally hosted recipes in your org.
The server starts at `http://localhost:5173`; the guest recipes are served under `/embedding/*` (e.g. `http://localhost:5173/embedding/basic-embed`). Keep this running while using the externally hosted recipes in your org.

1. Deploy the LWC host components:

```bash
cd ..
sf project deploy start --source-dir force-app/main/default/lwc
```

This deploys both the `mfe*` recipe wrappers and `vendorLwcShell`, which registers the `<lwc-shell>` custom element they all rely on. `vendorLwcShell` is a vendored bundle of [`@salesforce/experimental-mfe-lwc-shell`](https://www.npmjs.com/package/@salesforce/experimental-mfe-lwc-shell) — it's checked into the repo, so you don't need to build it yourself. Refresh from npm when a new version ships.

1. Add a host component to a Lightning page:

```bash
sf org open
cd ../../../../..
sf project deploy start -d force-app/main/default/lwc
```

In the org, go to **Setup → Lightning App Builder → New → App Page**, drag a *Custom* component (e.g. `mfeBasicEmbed`, `mfeReceiveData`) from the left panel onto the canvas, then **Save → Activate** and pick the apps where it should appear. Open the page from App Launcher to use the recipe.
1. Open the scratch org, go to App Launcher, and search for any of the host components (`mfeBasicEmbed`, `mfeReceiveData`, etc.) to add them to a Lightning page.

### Available externally hosted recipes

| LWC host component | External app route | What it demonstrates |
| LWC host component | Guest route | What it demonstrates |
|---|---|---|
| `mfeBasicEmbed` | `/basic-embed` | Minimum viable embed — bridge connection detection |
| `mfeReceiveData` | `/receive-data` | Host pushes data into guest via `shell.updateData()` |
| `mfeSendEvent` | `/send-event` | Guest dispatches events to host via `bridge.dispatchEvent()` |
| `mfeAutoResize` | `/auto-resize` | iframe height follows guest content via ResizeObserver |
| `mfeThemeTokens` | `/theme-tokens` | Salesforce CSS custom properties synced to guest |
| `mfeDirtyState` | `/dirty-state` | Guest notifies host of unsaved changes |
| `mfeBasicEmbed` | `/embedding/basic-embed` | Minimum viable embed using `<lightning-embedding>` — `chatSDK.getHostContext()` for connection detection |
| `mfeReceiveData` | `/embedding/receive-data` | Host re-mounts `<lightning-embedding>` with new src carrying URL query params; guest reads via `URLSearchParams` and `viewSDK.getUiProps()` |
| `mfeSendEvent` | `/embedding/send-event` | Guest calls `viewSDK.dispatchEvent(name, data)`; surface-level routing is host-runtime specific |
| `mfeAutoResize` | `/embedding/auto-resize` | Guest tracks body height with ResizeObserver and calls `viewSDK.resize()` |
| `mfeThemeTokens` | `/embedding/theme-tokens` | Guest reads host theme via `viewSDK.getTheme()` and the broader environment via `chatSDK.getHostContext()` |
| `mfeDirtyState` | `/embedding/dirty-state` | Guest calls `viewSDK.markDirtyState()` / `clearDirtyState()` to signal unsaved changes |

### Pointing at a deployed external app

For production or sandbox use, deploy the external app to a hosted URL and set the `baseUrl` property on each LWC host component to point at that URL instead of `localhost:4300`.
For production or sandbox use, deploy the guest app to a hosted URL and set the `baseUrl` property on each LWC host component to point at that URL instead of `localhost:5173`.

## Local Development

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
<canAccessCamera>false</canAccessCamera>
<canAccessMicrophone>false</canAccessMicrophone>
<context>All</context>
<description>Local MFE app dev server — allows the lwc-shell iframe to load from localhost:4300</description>
<endpointUrl>http://localhost:4300</endpointUrl>
<description>React Recipes dev server — allows the lightning-embedding iframe to load externally-hosted MFE recipe routes from localhost:5173</description>
<endpointUrl>http://localhost:5173</endpointUrl>
<isActive>true</isActive>
<isApplicableToConnectSrc>false</isApplicableToConnectSrc>
<isApplicableToFontSrc>false</isApplicableToFontSrc>
Expand Down
10 changes: 10 additions & 0 deletions force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* Override lightning-ui-embedding's default :host { height: 100% }. The bridge
* sets an explicit height in px on the inner iframe when the guest sends
* ui/notifications/resize; letting the host element flow means the LWC
* grows/shrinks to that height instead of clipping/scrolling inside a
* fixed-height container. */
.auto-resize-embed {
display: block;
height: auto;
min-height: 120px;
}
18 changes: 14 additions & 4 deletions force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@
<lightning-card title="MFE Auto-Resize" icon-name="custom:custom59">
<div class="slds-p-around_medium">
<p class="slds-text-body_regular slds-m-bottom_medium">
Add or remove items in the MFE — the iframe height adjusts automatically.
No fixed height is set. The bridge reports content height changes via a
ResizeObserver.
Add or remove items in the MFE — the guest's bootstrap-attached
<code>EmbeddingResizer</code> sends
<code>ui/notifications/resize</code> and
<code>&lt;lightning-ui-embedding&gt;</code> applies the height to
its inner iframe.
</p>
<div class="shell-container" lwc:dom="manual"></div>
<div class="shell-container">
<lightning-ui-embedding
class="auto-resize-embed"
sandbox="allow-forms allow-modals"
shell-title="MFE Auto-Resize"
debug={debug}
src={computedSrc}
></lightning-ui-embedding>
</div>
</div>
</lightning-card>
</template>
40 changes: 3 additions & 37 deletions force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,12 @@
import { LightningElement, api } from 'lwc';
import 'c/vendorLwcShell';

export default class MfeAutoResize extends LightningElement {
@api baseUrl = 'http://localhost:4300';
_shellElement;
@api baseUrl = 'http://localhost:5173';
debug = true;

get computedSrc() {
const url = new URL(this.baseUrl);
url.pathname = '/auto-resize';
url.pathname = '/embedding/auto-resize';
return url.toString();
}

renderedCallback() {
this._initializeShell();
}

disconnectedCallback() {
this._shellElement = null;
}

_initializeShell() {
if (this._shellElement) {
return;
}
const container = this.template.querySelector('.shell-container');
if (!container) {
return;
}
const shell = document.createElement('lwc-shell');
shell.sandbox = 'allow-forms allow-modals';
shell.title = 'MFE Auto-Resize';
shell.src = this.computedSrc;

// No explicit resize handler needed — lwc-shell receives 'resize' events
// from the bridge and updates iframe.style.height automatically.
// Cancel the event here if you need a fixed height instead.
shell.addEventListener('widget-ready', () => {
// eslint-disable-next-line no-console
console.log('[MfeAutoResize] widget-ready');
});

container.appendChild(shell);
this._shellElement = shell;
}
}
13 changes: 10 additions & 3 deletions force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
<lightning-card title="MFE Basic Embed" icon-name="custom:custom59">
<div class="slds-p-around_medium">
<p class="slds-text-body_regular slds-m-bottom_medium">
Minimum viable embed. Creates an <code>lwc-shell</code> pointing at the
external MFE app and handles the <code>widget-ready</code> event.
Minimum viable embed using the standard
<code>&lt;lightning-ui-embedding&gt;</code> base component.
</p>
<div class="shell-container" lwc:dom="manual"></div>
<div class="shell-container" style="height: 400px">
<lightning-ui-embedding
sandbox="allow-forms allow-modals"
shell-title="MFE Basic Embed"
debug={debug}
src={computedSrc}
></lightning-ui-embedding>
</div>
</div>
</lightning-card>
</template>
35 changes: 3 additions & 32 deletions force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,12 @@
import { LightningElement, api } from 'lwc';
import 'c/vendorLwcShell';

export default class MfeBasicEmbed extends LightningElement {
@api baseUrl = 'http://localhost:4300';
_shellElement;
@api baseUrl = 'http://localhost:5173';
debug = true;

get computedSrc() {
const url = new URL(this.baseUrl);
url.pathname = '/basic-embed';
url.pathname = '/embedding/basic-embed';
return url.toString();
}

renderedCallback() {
this._initializeShell();
}

disconnectedCallback() {
this._shellElement = null;
}

_initializeShell() {
if (this._shellElement) {
return;
}
const container = this.template.querySelector('.shell-container');
if (!container) {
return;
}
const shell = document.createElement('lwc-shell');
shell.sandbox = 'allow-forms allow-modals';
shell.title = 'MFE Basic Embed';
shell.src = this.computedSrc;
shell.addEventListener('widget-ready', () => {
// eslint-disable-next-line no-console
console.log('[MfeBasicEmbed] widget-ready');
});
container.appendChild(shell);
this._shellElement = shell;
}
}
33 changes: 24 additions & 9 deletions force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.html
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
<template>
<lightning-card title="MFE Dirty State" icon-name="custom:custom59">
<div class="slds-p-around_medium">
<div class="slds-p-around_medium" ontrackdirtystate={handleDirtyState}>
<p class="slds-text-body_regular slds-m-bottom_medium">
Type into any field in the MFE form. This component receives a
<code>trackdirtystate</code> event, re-dispatches it to Lightning
Experience (which blocks tab navigation), and attaches a
<code>beforeunload</code> handler so the browser also warns on
close or reload.
Edit the MFE form — the guest calls
<code>viewSDK.markDirtyState()</code> on every change and
<code>viewSDK.clearDirtyState()</code> after save. The host
surface decides how to honour the dirty signal (e.g. block
navigation).
</p>
<div class={warningClass} role="alert">
<span>&#9888; {dirtyLabel} — save or discard before leaving.</span>

<template lwc:if={hasSignal}>
<div if:true={isDirty} class="slds-notify slds-notify_alert slds-theme_alert-texture slds-theme_warning slds-m-bottom_medium">
Host received <code>trackdirtystate</code> — MFE is dirty
<template lwc:if={label}>&nbsp;({label})</template>.
</div>
<div if:false={isDirty} class="slds-notify slds-notify_alert slds-theme_alert-texture slds-theme_success slds-m-bottom_medium">
Host received <code>trackdirtystate</code> — MFE is clean.
</div>
</template>

<div class="shell-container" style="height: 400px">
<lightning-ui-embedding
sandbox="allow-forms allow-modals"
shell-title="MFE Dirty State"
debug={debug}
src={computedSrc}
></lightning-ui-embedding>
</div>
<div class="shell-container" lwc:dom="manual"></div>
</div>
</lightning-card>
</template>
Loading
Loading