diff --git a/.gitignore b/.gitignore index 04d8c84..d1365c7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ deploy-options.json # LWC VSCode autocomplete jsconfig.json +#dist +dist/ + +# TypeScript incremental build cache +*.tsbuildinfo + # LWC Jest coverage reports coverage/ diff --git a/README.md b/README.md index 33b78f5..10f4b7a 100644 --- a/README.md +++ b/README.md @@ -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 `` 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
mfe-app/] -->|iframe src| B[lwc-shell] + A[Guest recipes
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 @@ -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 `` 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 `` 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 `` — `chatSDK.getHostContext()` for connection detection | +| `mfeReceiveData` | `/embedding/receive-data` | Host re-mounts `` 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 diff --git a/force-app/main/default/cspTrustedSites/MfeAppLocalhost.cspTrustedSite-meta.xml b/force-app/main/default/cspTrustedSites/ReactRecipesLocalhost.cspTrustedSite-meta.xml similarity index 74% rename from force-app/main/default/cspTrustedSites/MfeAppLocalhost.cspTrustedSite-meta.xml rename to force-app/main/default/cspTrustedSites/ReactRecipesLocalhost.cspTrustedSite-meta.xml index 0987ccd..741c3f3 100644 --- a/force-app/main/default/cspTrustedSites/MfeAppLocalhost.cspTrustedSite-meta.xml +++ b/force-app/main/default/cspTrustedSites/ReactRecipesLocalhost.cspTrustedSite-meta.xml @@ -3,8 +3,8 @@ false false All - Local MFE app dev server — allows the lwc-shell iframe to load from localhost:4300 - http://localhost:4300 + React Recipes dev server — allows the lightning-embedding iframe to load externally-hosted MFE recipe routes from localhost:5173 + http://localhost:5173 true false false diff --git a/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.css b/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.css new file mode 100644 index 0000000..c380e7b --- /dev/null +++ b/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.css @@ -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; +} diff --git a/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.html b/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.html index b5e5c03..edaaa11 100644 --- a/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.html +++ b/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.html @@ -2,11 +2,21 @@

- 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 + EmbeddingResizer sends + ui/notifications/resize and + <lightning-ui-embedding> applies the height to + its inner iframe.

-
+
+ +
diff --git a/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.js b/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.js index 58b1400..1ef6580 100644 --- a/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.js +++ b/force-app/main/default/lwc/mfeAutoResize/mfeAutoResize.js @@ -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; - } } diff --git a/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.html b/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.html index 89cc3b6..ab11fd1 100644 --- a/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.html +++ b/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.html @@ -2,10 +2,17 @@

- Minimum viable embed. Creates an lwc-shell pointing at the - external MFE app and handles the widget-ready event. + Minimum viable embed using the standard + <lightning-ui-embedding> base component.

-
+
+ +
diff --git a/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.js b/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.js index 62652a4..40d3c49 100644 --- a/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.js +++ b/force-app/main/default/lwc/mfeBasicEmbed/mfeBasicEmbed.js @@ -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; - } } diff --git a/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.html b/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.html index 8b3353c..bd030fc 100644 --- a/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.html +++ b/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.html @@ -1,17 +1,32 @@ diff --git a/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.js b/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.js index 1cdbb2e..3a16f5a 100644 --- a/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.js +++ b/force-app/main/default/lwc/mfeDirtyState/mfeDirtyState.js @@ -1,101 +1,25 @@ import { LightningElement, api, track } from 'lwc'; -import 'c/vendorLwcShell'; export default class MfeDirtyState extends LightningElement { - @api baseUrl = 'http://localhost:4300'; + @api baseUrl = 'http://localhost:5173'; + debug = false; + + @track hasSignal = false; @track isDirty = false; - @track dirtyLabel = ''; - _shellElement; - _beforeUnloadHandler; + @track label = ''; get computedSrc() { const url = new URL(this.baseUrl); - url.pathname = '/dirty-state'; + url.pathname = '/embedding/dirty-state'; return url.toString(); } - get warningClass() { - return this.isDirty - ? 'slds-notify slds-notify_alert slds-theme_warning slds-m-bottom_small' - : 'slds-hide'; - } - - renderedCallback() { - this._initializeShell(); - } - - disconnectedCallback() { - this._shellElement = null; - this._detachBeforeUnload(); - } - - _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 Dirty State'; - shell.src = this.computedSrc; - - // trackdirtystate fires when the MFE calls - // bridge.dispatchEvent(new CustomEvent('trackdirtystate', { detail: { isDirty, label } })) - shell.addEventListener('trackdirtystate', (evt) => { - const isDirty = !!evt.detail.isDirty; - const label = evt.detail.label ?? 'Unsaved changes'; - this.isDirty = isDirty; - this.dirtyLabel = label; - - // Re-dispatch from the LWC itself so Lightning Experience picks it - // up and blocks navigation with its standard unsaved-changes dialog. - this.dispatchEvent( - new CustomEvent('trackdirtystate', { - detail: { isDirty, label }, - bubbles: true, - composed: true, - }) - ); - - // Belt-and-suspenders: also warn on browser-level navigation - // (tab close, reload, URL change). Works when the component is - // hosted outside a Lightning context too. - if (isDirty) { - this._attachBeforeUnload(); - } else { - this._detachBeforeUnload(); - } - }); - - shell.addEventListener('widget-ready', () => { - // eslint-disable-next-line no-console - console.log('[MfeDirtyState] widget-ready'); - }); - - container.appendChild(shell); - this._shellElement = shell; - } - - _attachBeforeUnload() { - if (this._beforeUnloadHandler) { - return; - } - this._beforeUnloadHandler = (evt) => { - evt.preventDefault(); - // Modern browsers ignore the custom string but require returnValue set. - evt.returnValue = ''; - }; - window.addEventListener('beforeunload', this._beforeUnloadHandler); - } - - _detachBeforeUnload() { - if (!this._beforeUnloadHandler) { - return; - } - window.removeEventListener('beforeunload', this._beforeUnloadHandler); - this._beforeUnloadHandler = null; + // Fires when re-dispatches the wire event on the LWC. + // The event bubbles + composes, so any ancestor of the embedding can listen. + handleDirtyState(evt) { + const { isDirty, label } = evt.detail ?? {}; + this.hasSignal = true; + this.isDirty = Boolean(isDirty); + this.label = typeof label === 'string' ? label : ''; } } diff --git a/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.html b/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.html index 602042f..3a561f9 100644 --- a/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.html +++ b/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.html @@ -2,8 +2,9 @@

- Type a message and click Send — the MFE receives it via - bridge.addEventListener('data', handler). + Type a message and click Send — the host publishes it via the + props binding on <lightning-ui-embedding>. + The guest reads it through viewSDK.getUiState().

-
+
+ +
diff --git a/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.js b/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.js index 7b144b3..0564988 100644 --- a/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.js +++ b/force-app/main/default/lwc/mfeReceiveData/mfeReceiveData.js @@ -1,54 +1,28 @@ import { LightningElement, api, track } from 'lwc'; -import 'c/vendorLwcShell'; export default class MfeReceiveData extends LightningElement { - @api baseUrl = 'http://localhost:4300'; + @api baseUrl = 'http://localhost:5173'; @track inputValue = 'Hello from Salesforce'; - _shellElement; + @track payload = {}; + debug = true; get computedSrc() { const url = new URL(this.baseUrl); - url.pathname = '/receive-data'; + url.pathname = '/embedding/receive-data'; return url.toString(); } - renderedCallback() { - this._initializeShell(); - } - - disconnectedCallback() { - this._shellElement = null; - } - handleInputChange(evt) { this.inputValue = evt.target.value; } handleSend() { - if (!this._shellElement) { - return; - } - // updateData() sends a postMessage into the MFE iframe. - // The MFE receives it via bridge.addEventListener('data', handler). - this._shellElement.updateData({ message: this.inputValue, timestamp: Date.now() }); - } - - _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 Receive Data'; - shell.src = this.computedSrc; - shell.addEventListener('widget-ready', () => { - this._shellElement.updateData({ message: this.inputValue, timestamp: Date.now() }); - }); - container.appendChild(shell); - this._shellElement = shell; + // `props` on is the canonical host → guest channel + // (ui/notifications/ui-state). Each new object identity schedules a + // ui-state-changed flush; the guest sees it as `state.props`. + this.payload = { + message: this.inputValue, + timestamp: Date.now(), + }; } } diff --git a/force-app/main/default/lwc/mfeSendEvent/mfeSendEvent.html b/force-app/main/default/lwc/mfeSendEvent/mfeSendEvent.html index 4bd4072..31aaccf 100644 --- a/force-app/main/default/lwc/mfeSendEvent/mfeSendEvent.html +++ b/force-app/main/default/lwc/mfeSendEvent/mfeSendEvent.html @@ -1,16 +1,33 @@