From 029289bca59fa7b90aa279a2b6ac36b5e19c2a31 Mon Sep 17 00:00:00 2001 From: Byrne Hollander Date: Wed, 29 Apr 2026 14:01:19 -0400 Subject: [PATCH 1/6] Rewrite react embed for ESM compatibility --- packages/react-embed/README.md | 16 +- .../examples/simple/EmbedFlowExample.tsx | 4 +- .../examples/simple/lib/EmbedFlowExample.d.ts | 3 +- .../examples/simple/lib/EmbedFlowExample.js | 5 +- .../react-embed/examples/simple/tsconfig.json | 1 + packages/react-embed/package.json | 20 +- .../src/__tests__/EmbedFlow.test.tsx | 265 ++++++++++++++--- packages/react-embed/src/index.tsx | 277 ++++++++++++++---- packages/react-embed/tsconfig.build.cjs.json | 12 + packages/react-embed/tsconfig.build.esm.json | 12 + packages/react-embed/tsconfig.build.json | 5 +- .../react-embed/tsconfig.build.types.json | 3 + packages/react-embed/tsconfig.json | 1 + 13 files changed, 511 insertions(+), 113 deletions(-) create mode 100644 packages/react-embed/tsconfig.build.cjs.json create mode 100644 packages/react-embed/tsconfig.build.esm.json create mode 100644 packages/react-embed/tsconfig.build.types.json diff --git a/packages/react-embed/README.md b/packages/react-embed/README.md index 1ce10883..88c2e919 100644 --- a/packages/react-embed/README.md +++ b/packages/react-embed/README.md @@ -3,6 +3,7 @@ Embed [Formsort](https://formsort.com) flows within react components. This is a handy wrapper around [@formsort/web-embed-api](https://github.com/formsort/oss/tree/master/packages/web-embed-api). +The package publishes both ESM and CommonJS entrypoints. **Important note:** This package is intended for use in React web applications. If you're looking to embed Formsort flows in React Native applications, please see [React native embed guide](./ReactNativeEmbed.md). @@ -15,10 +16,9 @@ Add `@formsort/react-embed` to your project by executing `yarn add @formsort/rea Here's an example of basic usage: ```tsx -import React from 'react'; import EmbedFlow from '@formsort/react-embed'; -const EmbedFlowExample: React.FunctionComponent = () => ( +const EmbedFlowExample = () => (
( ### Events -You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all event listeners](https://github.com/formsort/oss/tree/master/packages/web-embed-api#event-listeners) +You can add event listeners to flows like `FlowLoaded`, `redirect` etc. See [all event listeners](https://github.com/formsort/oss/tree/master/packages/web-embed-api#event-listeners) ### Props @@ -54,8 +54,9 @@ You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all | onFlowClosed | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#flowclosed-answers--key-string-any---void) | no | `() => { console.log('flow closed') }` | | onFlowFinalized | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#flowfinalized-answers--key-string-any---void) | no | `() => { console.log('flow finalized') }` | | onStepLoaded | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step loaded') }` | -| onStepCompleted | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step loaded') }` | -| onRedirect | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#redirect--url-string-answers--key-string-any-----cancel-boolean---undefined) | no | `(url: string) => { console.log('redirecting to:', url) }` | +| onStepCompleted | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step completed') }` | +| onResponderStateUpdated | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#event-listeners) | no | `() => { console.log('responder state updated') }` | +| onRedirect | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#redirect--url-string-answers--key-string-any-----cancel-boolean---undefined) | no | `({ url }) => { console.log('redirecting to:', url) }` | | onUnauthorized | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#unauthorized---void) | no | `() => { console.log('ID token is missing or invalid.') }` | ### Loading a specific variant revision @@ -63,16 +64,15 @@ You can add event listeners to flows like `Flowloaded`, `redirect` etc. See [all You can use query parameters to load a specific variant revision. Don't use it if you want to show latest variant. ```tsx -import React from 'react'; import EmbedFlow from '@formsort/react-embed'; -const EmbedFlowExample: React.FunctionComponent = () => ( +const EmbedFlowExample = () => (
']} + queryParams={[['variantRevisionUuid', '']]} />
); diff --git a/packages/react-embed/examples/simple/EmbedFlowExample.tsx b/packages/react-embed/examples/simple/EmbedFlowExample.tsx index ff4c2f04..e4a4df15 100644 --- a/packages/react-embed/examples/simple/EmbedFlowExample.tsx +++ b/packages/react-embed/examples/simple/EmbedFlowExample.tsx @@ -1,8 +1,6 @@ import EmbedFlow from '@formsort/react-embed'; -import React from 'react'; - -const EmbedFlowExample: React.FunctionComponent = () => ( +const EmbedFlowExample = () => (
JSX.Element; export default EmbedFlowExample; diff --git a/packages/react-embed/examples/simple/lib/EmbedFlowExample.js b/packages/react-embed/examples/simple/lib/EmbedFlowExample.js index 1b7433c3..d4566702 100644 --- a/packages/react-embed/examples/simple/lib/EmbedFlowExample.js +++ b/packages/react-embed/examples/simple/lib/EmbedFlowExample.js @@ -1,8 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); +const jsx_runtime_1 = require("react/jsx-runtime"); const react_embed_1 = tslib_1.__importDefault(require("@formsort/react-embed")); -const react_1 = tslib_1.__importDefault(require("react")); -const EmbedFlowExample = () => (react_1.default.createElement("div", null, - react_1.default.createElement(react_embed_1.default, { clientLabel: "formsort", flowLabel: "onboarding", variantLabel: "main" }))); +const EmbedFlowExample = () => ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(react_embed_1.default, { clientLabel: "formsort", flowLabel: "onboarding", variantLabel: "main" }) })); exports.default = EmbedFlowExample; diff --git a/packages/react-embed/examples/simple/tsconfig.json b/packages/react-embed/examples/simple/tsconfig.json index 27a3b628..768c23b3 100644 --- a/packages/react-embed/examples/simple/tsconfig.json +++ b/packages/react-embed/examples/simple/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@formsort/tsconfig", "compilerOptions": { "declaration": true, + "jsx": "react-jsx", "outDir": "./lib", "strict": true, "types": ["jest"] diff --git a/packages/react-embed/package.json b/packages/react-embed/package.json index 1aeb63e8..6d6c6bbe 100644 --- a/packages/react-embed/package.json +++ b/packages/react-embed/package.json @@ -2,17 +2,29 @@ "name": "@formsort/react-embed", "version": "3.5.0", "description": "Embed formsort flows in react components", + "type": "module", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "./lib/cjs/index.cjs", + "module": "./lib/esm/index.js", + "types": "./lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.cjs", + "default": "./lib/esm/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "directories": { "example": "example" }, "scripts": { - "build": "tsc --project tsconfig.build.json", + "build": "rm -rf lib && tsc --project tsconfig.build.esm.json && tsc --project tsconfig.build.cjs.json && mv lib/cjs/index.js lib/cjs/index.cjs && tsc --project tsconfig.build.types.json", "test": "jest", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", @@ -61,7 +73,7 @@ "@formsort/web-embed-api": "^2.10.0" }, "peerDependencies": { - "react": ">=16.13.0, <19.0.0" + "react": ">=16.13.0 <20.0.0" }, "jest": { "cacheDirectory": "./.jest-cache", diff --git a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx index 800b4357..5a89a607 100644 --- a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx +++ b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx @@ -1,41 +1,75 @@ -import FormsortWebEmbed, { IFormsortWebEmbed } from '@formsort/web-embed-api'; -import { render } from '@testing-library/react'; -import React from 'react'; +import FormsortWebEmbed, { + SupportedAnalyticsEvent, + type IEventMap, + type IFormsortWebEmbed, +} from '@formsort/web-embed-api'; +import { act, render } from '@testing-library/react'; +import React, { StrictMode } from 'react'; import EmbedFlow from '..'; -jest.mock('@formsort/web-embed-api'); +jest.mock('@formsort/web-embed-api', () => ({ + __esModule: true, + default: jest.fn(), + SupportedAnalyticsEvent: { + FlowLoaded: 'FlowLoaded', + FlowClosed: 'FlowClosed', + FlowFinalized: 'FlowFinalized', + StepLoaded: 'StepLoaded', + StepCompleted: 'StepCompleted', + ResponderStateUpdated: 'ResponderStateUpdated', + }, +})); const mockWebEmbedApi = FormsortWebEmbed as jest.MockedFunction< typeof FormsortWebEmbed >; +const getAddedListener = ( + addEventListenerMock: jest.Mock, + eventName: K +): IEventMap[K] => { + const listenerCall = addEventListenerMock.mock.calls.find( + ([addedEventName]) => addedEventName === eventName + ); + + if (!listenerCall) { + throw new Error(`Expected ${String(eventName)} listener to be registered`); + } + + return listenerCall[1] as IEventMap[K]; +}; + describe('EmbedFlow component', () => { let loadMock: jest.Mock; + let unloadMock: jest.Mock; let embedMock: IFormsortWebEmbed; let addEventListenerMock: jest.Mock; let removeEventListenerMock: jest.Mock; beforeEach(() => { loadMock = jest.fn(); + unloadMock = jest.fn(); addEventListenerMock = jest.fn(); removeEventListenerMock = jest.fn(); embedMock = { loadFlow: loadMock, - unloadFlow: jest.fn(), + unloadFlow: unloadMock, setSize: jest.fn(), addEventListener: addEventListenerMock, removeEventListener: removeEventListenerMock, }; - mockWebEmbedApi.mockReturnValueOnce(embedMock); + mockWebEmbedApi.mockReturnValue(embedMock); }); + afterEach(() => { - mockWebEmbedApi.mockClear(); + mockWebEmbedApi.mockReset(); }); - test('should load flows without variant label', () => { + test('loads flows without variant label', () => { render(); - expect(loadMock).toBeCalledWith( + + expect(loadMock).toHaveBeenCalledWith( 'test-client', 'test-flow', undefined, @@ -43,7 +77,7 @@ describe('EmbedFlow component', () => { ); }); - test('should load flows with variant label', () => { + test('loads flows with variant label', () => { render( { variantLabel="test-variant" /> ); - expect(loadMock).toBeCalledWith( + + expect(loadMock).toHaveBeenCalledWith( 'test-client', 'test-flow', 'test-variant', @@ -59,65 +94,225 @@ describe('EmbedFlow component', () => { ); }); - test('should pass down the event listeners given as props', () => { - const flowloadedMock = jest.fn(); + test('passes embed config to the web embed and applies style to the container', () => { + const embedConfig = { + iframeTitle: 'Example flow', + style: { + width: '80%', + height: '400px', + }, + }; + + const { container } = render( + + ); + + expect(mockWebEmbedApi).toHaveBeenCalledWith( + container.firstChild, + embedConfig + ); + expect((container.firstChild as HTMLDivElement).style.width).toBe('80%'); + expect((container.firstChild as HTMLDivElement).style.height).toBe('400px'); + }); + + test('does not reload when rerendered with an equivalent inline embed config', () => { + const { rerender } = render( + + ); + + rerender( + + ); + + expect(mockWebEmbedApi).toHaveBeenCalledTimes(1); + expect(loadMock).toHaveBeenCalledTimes(1); + expect(unloadMock).not.toHaveBeenCalled(); + }); + + test('bridges web embed events to the latest React callbacks', () => { + const flowLoadedMock = jest.fn(); + const updatedFlowLoadedMock = jest.fn(); const flowFinalizedMock = jest.fn(); - const redirectMock = jest.fn(); + const redirectMock = jest.fn().mockReturnValue({ cancel: true }); const unauthorizedMock = jest.fn(); - render( + const { rerender } = render( ); - expect(loadMock).toBeCalledWith( - 'test-client', - 'test-flow', - 'test-variant', - undefined + rerender( + + ); + + const flowLoadedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowLoaded + ); + const flowFinalizedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowFinalized + ); + const redirectListener = getAddedListener( + addEventListenerMock, + 'redirect' ); - expect(embedMock.addEventListener).toHaveBeenCalledTimes(5); - expect(embedMock.addEventListener).toBeCalledWith( - 'FlowLoaded', - flowloadedMock + const unauthorizedListener = getAddedListener( + addEventListenerMock, + 'unauthorized' ); - expect(embedMock.addEventListener).toBeCalledWith( - 'FlowFinalized', - flowFinalizedMock + const eventPayload = { + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + variantRevisionUuid: 'variant-revision-uuid', + stepId: 'step-id', + stepIndex: 0, + answerSources: {}, + }; + const redirectPayload = { + url: 'https://example.com', + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + }; + + flowLoadedListener(eventPayload); + flowFinalizedListener(eventPayload); + const redirectResult = redirectListener(redirectPayload); + unauthorizedListener(); + + expect(flowLoadedMock).not.toHaveBeenCalled(); + expect(updatedFlowLoadedMock).toHaveBeenCalledWith(eventPayload); + expect(flowFinalizedMock).toHaveBeenCalledWith(eventPayload); + expect(redirectMock).toHaveBeenCalledWith(redirectPayload); + expect(redirectResult).toEqual({ cancel: true }); + expect(unauthorizedMock).toHaveBeenCalled(); + }); + + test('hides the container after FlowClosed and calls the React callback', () => { + const flowClosedMock = jest.fn(); + const { container } = render( + + ); + + const flowClosedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowClosed ); - expect(embedMock.addEventListener).toBeCalledWith('redirect', redirectMock); - expect(embedMock.addEventListener).toBeCalledWith('unauthorized', unauthorizedMock); - // Check for FlowClosed event listener that removes the parent container of the embed - expect(embedMock.addEventListener).toBeCalledWith('FlowClosed', expect.any(Function)); + const eventPayload = { + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + variantRevisionUuid: 'variant-revision-uuid', + stepId: 'step-id', + stepIndex: 0, + answerSources: {}, + }; + + act(() => { + flowClosedListener(eventPayload); + }); + + expect(flowClosedMock).toHaveBeenCalledWith(eventPayload); + expect(container.firstChild).toBeNull(); }); - test('should load flows with URL params', () => { + test('loads flows with URL params without mutating props', () => { const uuid = 'b1c7d9c8-f4b0-4f3f-9fc3-abf32ae8a061'; + const queryParams: Array<[string, string]> = [['name', 'Olivia']]; + render( ); - expect(loadMock).toBeCalledWith( + + expect(loadMock).toHaveBeenCalledWith( 'test-client', 'test-flow', 'test-variant', [ + ['name', 'Olivia'], ['responderUuid', uuid], ['formsortEnv', 'staging'], ] ); + expect(queryParams).toEqual([['name', 'Olivia']]); + }); + + test('unloads the flow and removes event listeners on unmount', () => { + const { unmount } = render( + + ); + + unmount(); + + expect(removeEventListenerMock).toHaveBeenCalledTimes(8); + expect(unloadMock).toHaveBeenCalledTimes(1); + }); + + test('cleans up the development StrictMode remount', () => { + const { unmount } = render( + + + + ); + + unmount(); + + expect(mockWebEmbedApi).toHaveBeenCalledTimes(2); + expect(unloadMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/react-embed/src/index.tsx b/packages/react-embed/src/index.tsx index c1195e60..c0c249d3 100644 --- a/packages/react-embed/src/index.tsx +++ b/packages/react-embed/src/index.tsx @@ -1,25 +1,33 @@ -import { SupportedAnalyticsEvent } from '@formsort/embed-messaging-manager'; -import FormsortWebEmbed, { +import * as WebEmbedApi from '@formsort/web-embed-api'; +import type { IEventMap, IFormsortWebEmbed, IFormsortWebEmbedConfig, } from '@formsort/web-embed-api'; - -import React, { useEffect, useRef, useState } from 'react'; +import { + createElement, + type CSSProperties, + type MutableRefObject, + type ReactElement, + useEffect, + useRef, + useState, +} from 'react'; // Using this type to preserve auto-complete for default environments // while allowing any other string to be passed. // See https://github.com/microsoft/TypeScript/issues/29729 type LiteralUnion = T | (U & Record); -type FormsortEnv = LiteralUnion<'staging' | 'production'>; -interface ILoadProps { +export type FormsortEnv = LiteralUnion<'staging' | 'production'>; + +export interface IEmbedFlowLoadProps { clientLabel: string; flowLabel: string; variantLabel?: string; responderUuid?: string; formsortEnv?: FormsortEnv; - queryParams?: Array<[string, string]>; + queryParams?: ReadonlyArray; embedConfig?: IFormsortWebEmbedConfig; } @@ -31,9 +39,70 @@ export interface IReactEmbedEventMap { onFlowFinalized?: IEventMap['FlowFinalized']; onStepLoaded?: IEventMap['StepLoaded']; onStepCompleted?: IEventMap['StepCompleted']; + onResponderStateUpdated?: IEventMap['ResponderStateUpdated']; } -export type EmbedFlowProps = ILoadProps & IReactEmbedEventMap; +export interface EmbedFlowProps + extends IEmbedFlowLoadProps, + IReactEmbedEventMap {} + +type FormsortWebEmbedFactory = ( + rootEl: HTMLElement, + config?: IFormsortWebEmbedConfig +) => IFormsortWebEmbed; + +const getFormsortWebEmbed = ( + moduleExport: typeof WebEmbedApi +): FormsortWebEmbedFactory => { + const defaultExport = moduleExport.default as unknown; + + if (typeof defaultExport === 'function') { + return defaultExport as FormsortWebEmbedFactory; + } + + const nestedDefault = + defaultExport && typeof defaultExport === 'object' + ? (defaultExport as unknown as { default?: FormsortWebEmbedFactory }) + .default + : undefined; + + if (typeof nestedDefault === 'function') { + return nestedDefault; + } + + throw new Error('Unable to load @formsort/web-embed-api default export'); +}; + +const getSupportedAnalyticsEvent = ( + moduleExport: typeof WebEmbedApi +): typeof WebEmbedApi.SupportedAnalyticsEvent => { + const directExport = ( + moduleExport as unknown as { + SupportedAnalyticsEvent?: typeof WebEmbedApi.SupportedAnalyticsEvent; + } + ).SupportedAnalyticsEvent; + + if (directExport) { + return directExport; + } + + const defaultExport = + moduleExport.default && + typeof (moduleExport.default as unknown) === 'object' + ? (moduleExport.default as unknown as { + SupportedAnalyticsEvent?: typeof WebEmbedApi.SupportedAnalyticsEvent; + }) + : undefined; + + if (defaultExport?.SupportedAnalyticsEvent) { + return defaultExport.SupportedAnalyticsEvent; + } + + throw new Error('Unable to load @formsort/web-embed-api event exports'); +}; + +const FormsortWebEmbed = getFormsortWebEmbed(WebEmbedApi); +const SupportedAnalyticsEvent = getSupportedAnalyticsEvent(WebEmbedApi); export const eventMapping: Record = { @@ -44,81 +113,175 @@ export const eventMapping: Record = onFlowFinalized: SupportedAnalyticsEvent.FlowFinalized, onStepLoaded: SupportedAnalyticsEvent.StepLoaded, onStepCompleted: SupportedAnalyticsEvent.StepCompleted, + onResponderStateUpdated: SupportedAnalyticsEvent.ResponderStateUpdated, }; -const attachEventListenersToEmbed = ( - embed: IFormsortWebEmbed, - events: IReactEmbedEventMap -): void => { - for (const [reactEventName, listener] of Object.entries(events)) { - const embedEventName = - eventMapping[reactEventName as keyof IReactEmbedEventMap]; - embed.addEventListener(embedEventName, listener); +const buildFlowQueryParams = ({ + queryParams, + responderUuid, + formsortEnv, +}: Pick< + IEmbedFlowLoadProps, + 'queryParams' | 'responderUuid' | 'formsortEnv' +>): Array<[string, string]> | undefined => { + const flowQueryParams: Array<[string, string]> = + queryParams?.map(([key, value]) => [key, value]) ?? []; + + if (responderUuid) { + flowQueryParams.push(['responderUuid', responderUuid]); } -}; -const onMount = ( - containerRef: React.RefObject, - props: EmbedFlowProps -): IFormsortWebEmbed | undefined => { - const containerElement = containerRef.current; - if (!containerElement) { - return; + if (formsortEnv) { + flowQueryParams.push(['formsortEnv', formsortEnv]); } - const { - clientLabel, - flowLabel, - variantLabel, - embedConfig, - responderUuid, - formsortEnv, - queryParams = [], - ...eventListeners - } = props; + return flowQueryParams.length ? flowQueryParams : undefined; +}; - const embed = FormsortWebEmbed(containerElement, embedConfig); - attachEventListenersToEmbed(embed, eventListeners); +const getQueryParamsKey = ( + queryParams: Array<[string, string]> | undefined +): string => JSON.stringify(queryParams ?? []); - if (responderUuid) { - queryParams.push(['responderUuid', responderUuid]); - } - if (formsortEnv) { - queryParams.push(['formsortEnv', formsortEnv]); +const getEmbedConfigKey = ( + embedConfig: IFormsortWebEmbedConfig | undefined +): string => { + try { + return JSON.stringify(embedConfig ?? {}); + } catch { + return String(embedConfig); } +}; - embed.loadFlow( - clientLabel, - flowLabel, - variantLabel, - queryParams.length ? queryParams : undefined - ); +const attachEventListener = ( + embed: IFormsortWebEmbed, + eventName: K, + eventListener: IEventMap[K] +): (() => void) => { + embed.addEventListener(eventName, eventListener); - return embed; + return () => { + embed.removeEventListener(eventName, eventListener); + }; }; -const EmbedFlow: React.FunctionComponent = (props) => { +const attachEventListeners = ( + embed: IFormsortWebEmbed, + eventListenersRef: MutableRefObject, + onFlowClosed: () => void +): Array<() => void> => [ + attachEventListener(embed, 'unauthorized', () => { + eventListenersRef.current.onUnauthorized?.(); + }), + attachEventListener(embed, 'redirect', (event) => + eventListenersRef.current.onRedirect?.(event) + ), + attachEventListener(embed, SupportedAnalyticsEvent.FlowLoaded, (event) => { + eventListenersRef.current.onFlowLoaded?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.FlowClosed, (event) => { + onFlowClosed(); + eventListenersRef.current.onFlowClosed?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.FlowFinalized, (event) => { + eventListenersRef.current.onFlowFinalized?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.StepLoaded, (event) => { + eventListenersRef.current.onStepLoaded?.(event); + }), + attachEventListener(embed, SupportedAnalyticsEvent.StepCompleted, (event) => { + eventListenersRef.current.onStepCompleted?.(event); + }), + attachEventListener( + embed, + SupportedAnalyticsEvent.ResponderStateUpdated, + (event) => { + eventListenersRef.current.onResponderStateUpdated?.(event); + } + ), +]; + +export const EmbedFlow = ({ + clientLabel, + flowLabel, + variantLabel, + responderUuid, + formsortEnv, + queryParams, + embedConfig, + onUnauthorized, + onRedirect, + onFlowLoaded, + onFlowClosed, + onFlowFinalized, + onStepLoaded, + onStepCompleted, + onResponderStateUpdated, +}: EmbedFlowProps): ReactElement | null => { const containerRef = useRef(null); - const style = props.embedConfig?.style; + const eventListenersRef = useRef({}); const [flowClosed, setFlowClosed] = useState(false); + eventListenersRef.current = { + onUnauthorized, + onRedirect, + onFlowLoaded, + onFlowClosed, + onFlowFinalized, + onStepLoaded, + onStepCompleted, + onResponderStateUpdated, + }; + + const flowQueryParams = buildFlowQueryParams({ + queryParams, + responderUuid, + formsortEnv, + }); + const queryParamsKey = getQueryParamsKey(flowQueryParams); + const embedConfigKey = getEmbedConfigKey(embedConfig); + useEffect(() => { - const embed = onMount(containerRef, props); + const containerElement = containerRef.current; - embed?.addEventListener(SupportedAnalyticsEvent.FlowClosed, () => { - setFlowClosed(true); - }) + if (!containerElement) { + return undefined; + } + + setFlowClosed(false); + + const embed = FormsortWebEmbed(containerElement, embedConfig); + const removeEventListeners = attachEventListeners( + embed, + eventListenersRef, + () => { + setFlowClosed(true); + } + ); + + embed.loadFlow(clientLabel, flowLabel, variantLabel, flowQueryParams); return () => { - embed?.unloadFlow(); + removeEventListeners.forEach((removeEventListener) => { + removeEventListener(); + }); + embed.unloadFlow(); }; - }, []); + }, [ + clientLabel, + embedConfigKey, + flowLabel, + queryParamsKey, + variantLabel, + ]); if (flowClosed) { return null; } - return
; + return createElement('div', { + ref: containerRef, + style: embedConfig?.style as CSSProperties | undefined, + }); }; export default EmbedFlow; diff --git a/packages/react-embed/tsconfig.build.cjs.json b/packages/react-embed/tsconfig.build.cjs.json new file mode 100644 index 00000000..650113dd --- /dev/null +++ b/packages/react-embed/tsconfig.build.cjs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "module": "CommonJS", + "outDir": "./lib/cjs", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["lib", "src/__tests__/**/*", "node_modules"] +} diff --git a/packages/react-embed/tsconfig.build.esm.json b/packages/react-embed/tsconfig.build.esm.json new file mode 100644 index 00000000..d7ff5668 --- /dev/null +++ b/packages/react-embed/tsconfig.build.esm.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "module": "ES2020", + "outDir": "./lib/esm", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["lib", "src/__tests__/**/*", "node_modules"] +} diff --git a/packages/react-embed/tsconfig.build.json b/packages/react-embed/tsconfig.build.json index 01b31932..caa3a167 100644 --- a/packages/react-embed/tsconfig.build.json +++ b/packages/react-embed/tsconfig.build.json @@ -1,7 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": "src" + "composite": false, + "rootDir": "src", + "outDir": "./lib/types", + "emitDeclarationOnly": true }, "include": ["src"], "exclude": ["lib", "src/__tests__/**/*", "node_modules"] diff --git a/packages/react-embed/tsconfig.build.types.json b/packages/react-embed/tsconfig.build.types.json new file mode 100644 index 00000000..d8faaf50 --- /dev/null +++ b/packages/react-embed/tsconfig.build.types.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.build.json" +} diff --git a/packages/react-embed/tsconfig.json b/packages/react-embed/tsconfig.json index b9946087..3b9accc5 100644 --- a/packages/react-embed/tsconfig.json +++ b/packages/react-embed/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@formsort/tsconfig", "compilerOptions": { "declaration": true, + "importHelpers": false, "outDir": "./lib", "strict": true, "types": ["jest"] From a48df15de370211e6521b20bee2ae02427020b08 Mon Sep 17 00:00:00 2001 From: Byrne Hollander Date: Wed, 29 Apr 2026 14:14:55 -0400 Subject: [PATCH 2/6] Add dual ESM and CJS outputs for embed packages --- packages/constants/package.json | 18 +++++++++++++++--- packages/constants/src/index.ts | 2 +- packages/constants/vite.config.mts | 12 ++++++++++++ packages/embed-messaging-manager/package.json | 19 +++++++++++++++---- .../embed-messaging-manager/vite.config.mts | 15 +++++++++++++++ packages/web-embed-api/package.json | 19 +++++++++++++++---- packages/web-embed-api/vite.config.mts | 15 +++++++++++++++ 7 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 packages/constants/vite.config.mts create mode 100644 packages/embed-messaging-manager/vite.config.mts create mode 100644 packages/web-embed-api/vite.config.mts diff --git a/packages/constants/package.json b/packages/constants/package.json index b293d006..96b2a14c 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -6,12 +6,23 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", + "main": "./lib/index.js", + "module": "./lib/esm/index.mjs", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js", + "default": "./lib/esm/index.mjs" + }, + "./package.json": "./package.json" + }, "directories": { "lib": "lib" }, "scripts": { - "build": "tsc --project tsconfig.build.json", + "build": "tsc --project tsconfig.build.json && vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", "test": "jest", @@ -32,7 +43,8 @@ "eslint": "^8.12.0", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "typescript": "^4.6.3" + "typescript": "^4.6.3", + "vite": "^6.4.2" }, "jest": { "cacheDirectory": "./.jest-cache", diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index d482c086..0745a099 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,7 +1,7 @@ export { default as CustomQuestionMessage } from './custom-question-message'; export { default as AnalyticsEventType } from './analytics-event-type'; export { default as WebEmbedMessage } from './web-embed-message'; -export { default as AnswerSemanticType } from './semantic-type'; +export type { default as AnswerSemanticType } from './semantic-type'; export * from './flow'; export * from './core'; export * from './embed-events'; diff --git a/packages/constants/vite.config.mts b/packages/constants/vite.config.mts new file mode 100644 index 00000000..b2e4db41 --- /dev/null +++ b/packages/constants/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + outDir: 'lib/esm', + }, +}); diff --git a/packages/embed-messaging-manager/package.json b/packages/embed-messaging-manager/package.json index 450bd395..b008f7c5 100644 --- a/packages/embed-messaging-manager/package.json +++ b/packages/embed-messaging-manager/package.json @@ -6,12 +6,22 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "./lib/index.js", + "module": "./lib/esm/index.mjs", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js", + "default": "./lib/esm/index.mjs" + }, + "./package.json": "./package.json" + }, "scripts": { "test": "jest", "coverage": "jest --coverage", - "build": "tsc --project tsconfig.build.json", + "build": "tsc --project tsconfig.build.json && vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", "pack": "yarn pack", @@ -31,7 +41,8 @@ "eslint": "^8.12.0", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vite": "^6.4.2" }, "jest": { "cacheDirectory": "./.jest-cache", diff --git a/packages/embed-messaging-manager/vite.config.mts b/packages/embed-messaging-manager/vite.config.mts new file mode 100644 index 00000000..5b768904 --- /dev/null +++ b/packages/embed-messaging-manager/vite.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + rollupOptions: { + external: ['@formsort/constants'], + }, + outDir: 'lib/esm', + }, +}); diff --git a/packages/web-embed-api/package.json b/packages/web-embed-api/package.json index 841f1353..95c98be3 100644 --- a/packages/web-embed-api/package.json +++ b/packages/web-embed-api/package.json @@ -6,12 +6,22 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "./lib/index.js", + "module": "./lib/esm/index.mjs", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js", + "default": "./lib/esm/index.mjs" + }, + "./package.json": "./package.json" + }, "scripts": { "test": "jest", "coverage": "jest --coverage", - "build": "tsc --project tsconfig.build.json", + "build": "tsc --project tsconfig.build.json && vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", "pack": "yarn pack", @@ -43,7 +53,8 @@ "jest": "^29.7.0", "prettier": "^2.2.1", "ts-jest": "^29.1.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vite": "^6.4.2" }, "dependencies": { "@formsort/embed-messaging-manager": "^0.3.0" diff --git a/packages/web-embed-api/vite.config.mts b/packages/web-embed-api/vite.config.mts new file mode 100644 index 00000000..5e23c903 --- /dev/null +++ b/packages/web-embed-api/vite.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + rollupOptions: { + external: ['@formsort/embed-messaging-manager'], + }, + outDir: 'lib/esm', + }, +}); From 1c2cc8ea84f982c1000fa1b237c2b55761d9fb3b Mon Sep 17 00:00:00 2001 From: Byrne Hollander Date: Wed, 29 Apr 2026 14:20:29 -0400 Subject: [PATCH 3/6] Add ESM output for custom question API --- packages/custom-question-api/package.json | 17 ++++++++++++++--- .../custom-question-api/vite.config.esm.mts | 15 +++++++++++++++ packages/embed-messaging-manager/package.json | 3 +++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 packages/custom-question-api/vite.config.esm.mts diff --git a/packages/custom-question-api/package.json b/packages/custom-question-api/package.json index fe78e204..0441c338 100644 --- a/packages/custom-question-api/package.json +++ b/packages/custom-question-api/package.json @@ -6,12 +6,23 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "./lib/index.js", + "module": "./lib/esm/index.mjs", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/index.js", + "default": "./lib/esm/index.mjs" + }, + "./package.json": "./package.json" + }, "unpkg": "umd/custom-question-api.min.js", "scripts": { - "build": "yarn build:package && yarn build:umd", + "build": "yarn build:package && yarn build:esm && yarn build:umd", "build:package": "tsc --project tsconfig.build.json", + "build:esm": "vite build --config vite.config.esm.mts", "build:umd": "vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", diff --git a/packages/custom-question-api/vite.config.esm.mts b/packages/custom-question-api/vite.config.esm.mts new file mode 100644 index 00000000..360febac --- /dev/null +++ b/packages/custom-question-api/vite.config.esm.mts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + rollupOptions: { + external: ['@formsort/constants', 'events'], + }, + outDir: 'lib/esm', + }, +}); diff --git a/packages/embed-messaging-manager/package.json b/packages/embed-messaging-manager/package.json index b008f7c5..f7652e72 100644 --- a/packages/embed-messaging-manager/package.json +++ b/packages/embed-messaging-manager/package.json @@ -27,6 +27,9 @@ "pack": "yarn pack", "release": "craft prepare --publish" }, + "files": [ + "lib/**/*" + ], "repository": { "type": "git", "url": "git+https://github.com/formsort/oss.git" From 8339f633d4e3b3c6930b7e38b4c38cd2ca4111de Mon Sep 17 00:00:00 2001 From: Byrne Hollander Date: Wed, 29 Apr 2026 14:27:34 -0400 Subject: [PATCH 4/6] Reduce embed modernization scope --- packages/custom-question-api/package.json | 17 +++-------------- .../custom-question-api/vite.config.esm.mts | 15 --------------- packages/embed-messaging-manager/package.json | 3 --- packages/react-embed/README.md | 1 - .../examples/simple/EmbedFlowExample.tsx | 4 +++- .../examples/simple/lib/EmbedFlowExample.d.ts | 3 ++- .../examples/simple/lib/EmbedFlowExample.js | 5 +++-- .../react-embed/examples/simple/tsconfig.json | 1 - .../src/__tests__/EmbedFlow.test.tsx | 3 +-- packages/react-embed/src/index.tsx | 11 ----------- 10 files changed, 12 insertions(+), 51 deletions(-) delete mode 100644 packages/custom-question-api/vite.config.esm.mts diff --git a/packages/custom-question-api/package.json b/packages/custom-question-api/package.json index 0441c338..fe78e204 100644 --- a/packages/custom-question-api/package.json +++ b/packages/custom-question-api/package.json @@ -6,23 +6,12 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "main": "./lib/index.js", - "module": "./lib/esm/index.mjs", - "types": "./lib/index.d.ts", - "exports": { - ".": { - "types": "./lib/index.d.ts", - "import": "./lib/esm/index.mjs", - "require": "./lib/index.js", - "default": "./lib/esm/index.mjs" - }, - "./package.json": "./package.json" - }, + "main": "lib/index.js", + "types": "lib/index.d.ts", "unpkg": "umd/custom-question-api.min.js", "scripts": { - "build": "yarn build:package && yarn build:esm && yarn build:umd", + "build": "yarn build:package && yarn build:umd", "build:package": "tsc --project tsconfig.build.json", - "build:esm": "vite build --config vite.config.esm.mts", "build:umd": "vite build", "format": "eslint --ext .ts,.tsx src --fix", "lint": "eslint --ext .ts,.tsx src", diff --git a/packages/custom-question-api/vite.config.esm.mts b/packages/custom-question-api/vite.config.esm.mts deleted file mode 100644 index 360febac..00000000 --- a/packages/custom-question-api/vite.config.esm.mts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({ - build: { - lib: { - entry: 'src/index.ts', - formats: ['es'], - fileName: () => 'index.mjs', - }, - rollupOptions: { - external: ['@formsort/constants', 'events'], - }, - outDir: 'lib/esm', - }, -}); diff --git a/packages/embed-messaging-manager/package.json b/packages/embed-messaging-manager/package.json index f7652e72..b008f7c5 100644 --- a/packages/embed-messaging-manager/package.json +++ b/packages/embed-messaging-manager/package.json @@ -27,9 +27,6 @@ "pack": "yarn pack", "release": "craft prepare --publish" }, - "files": [ - "lib/**/*" - ], "repository": { "type": "git", "url": "git+https://github.com/formsort/oss.git" diff --git a/packages/react-embed/README.md b/packages/react-embed/README.md index 88c2e919..f424d4c5 100644 --- a/packages/react-embed/README.md +++ b/packages/react-embed/README.md @@ -55,7 +55,6 @@ You can add event listeners to flows like `FlowLoaded`, `redirect` etc. See [all | onFlowFinalized | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#flowfinalized-answers--key-string-any---void) | no | `() => { console.log('flow finalized') }` | | onStepLoaded | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step loaded') }` | | onStepCompleted | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#steploaded-answers--key-string-any---void) | no | `() => { console.log('step completed') }` | -| onResponderStateUpdated | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#event-listeners) | no | `() => { console.log('responder state updated') }` | | onRedirect | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#redirect--url-string-answers--key-string-any-----cancel-boolean---undefined) | no | `({ url }) => { console.log('redirecting to:', url) }` | | onUnauthorized | [event listener](https://github.com/formsort/oss/tree/master/packages/web-embed-api#unauthorized---void) | no | `() => { console.log('ID token is missing or invalid.') }` | diff --git a/packages/react-embed/examples/simple/EmbedFlowExample.tsx b/packages/react-embed/examples/simple/EmbedFlowExample.tsx index e4a4df15..ff4c2f04 100644 --- a/packages/react-embed/examples/simple/EmbedFlowExample.tsx +++ b/packages/react-embed/examples/simple/EmbedFlowExample.tsx @@ -1,6 +1,8 @@ import EmbedFlow from '@formsort/react-embed'; +import React from 'react'; -const EmbedFlowExample = () => ( + +const EmbedFlowExample: React.FunctionComponent = () => (
JSX.Element; +import React from 'react'; +declare const EmbedFlowExample: React.FunctionComponent; export default EmbedFlowExample; diff --git a/packages/react-embed/examples/simple/lib/EmbedFlowExample.js b/packages/react-embed/examples/simple/lib/EmbedFlowExample.js index d4566702..1b7433c3 100644 --- a/packages/react-embed/examples/simple/lib/EmbedFlowExample.js +++ b/packages/react-embed/examples/simple/lib/EmbedFlowExample.js @@ -1,7 +1,8 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); -const jsx_runtime_1 = require("react/jsx-runtime"); const react_embed_1 = tslib_1.__importDefault(require("@formsort/react-embed")); -const EmbedFlowExample = () => ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(react_embed_1.default, { clientLabel: "formsort", flowLabel: "onboarding", variantLabel: "main" }) })); +const react_1 = tslib_1.__importDefault(require("react")); +const EmbedFlowExample = () => (react_1.default.createElement("div", null, + react_1.default.createElement(react_embed_1.default, { clientLabel: "formsort", flowLabel: "onboarding", variantLabel: "main" }))); exports.default = EmbedFlowExample; diff --git a/packages/react-embed/examples/simple/tsconfig.json b/packages/react-embed/examples/simple/tsconfig.json index 768c23b3..27a3b628 100644 --- a/packages/react-embed/examples/simple/tsconfig.json +++ b/packages/react-embed/examples/simple/tsconfig.json @@ -2,7 +2,6 @@ "extends": "@formsort/tsconfig", "compilerOptions": { "declaration": true, - "jsx": "react-jsx", "outDir": "./lib", "strict": true, "types": ["jest"] diff --git a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx index 5a89a607..0dadf864 100644 --- a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx +++ b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx @@ -17,7 +17,6 @@ jest.mock('@formsort/web-embed-api', () => ({ FlowFinalized: 'FlowFinalized', StepLoaded: 'StepLoaded', StepCompleted: 'StepCompleted', - ResponderStateUpdated: 'ResponderStateUpdated', }, })); @@ -299,7 +298,7 @@ describe('EmbedFlow component', () => { unmount(); - expect(removeEventListenerMock).toHaveBeenCalledTimes(8); + expect(removeEventListenerMock).toHaveBeenCalledTimes(7); expect(unloadMock).toHaveBeenCalledTimes(1); }); diff --git a/packages/react-embed/src/index.tsx b/packages/react-embed/src/index.tsx index c0c249d3..e778c317 100644 --- a/packages/react-embed/src/index.tsx +++ b/packages/react-embed/src/index.tsx @@ -39,7 +39,6 @@ export interface IReactEmbedEventMap { onFlowFinalized?: IEventMap['FlowFinalized']; onStepLoaded?: IEventMap['StepLoaded']; onStepCompleted?: IEventMap['StepCompleted']; - onResponderStateUpdated?: IEventMap['ResponderStateUpdated']; } export interface EmbedFlowProps @@ -113,7 +112,6 @@ export const eventMapping: Record = onFlowFinalized: SupportedAnalyticsEvent.FlowFinalized, onStepLoaded: SupportedAnalyticsEvent.StepLoaded, onStepCompleted: SupportedAnalyticsEvent.StepCompleted, - onResponderStateUpdated: SupportedAnalyticsEvent.ResponderStateUpdated, }; const buildFlowQueryParams = ({ @@ -191,13 +189,6 @@ const attachEventListeners = ( attachEventListener(embed, SupportedAnalyticsEvent.StepCompleted, (event) => { eventListenersRef.current.onStepCompleted?.(event); }), - attachEventListener( - embed, - SupportedAnalyticsEvent.ResponderStateUpdated, - (event) => { - eventListenersRef.current.onResponderStateUpdated?.(event); - } - ), ]; export const EmbedFlow = ({ @@ -215,7 +206,6 @@ export const EmbedFlow = ({ onFlowFinalized, onStepLoaded, onStepCompleted, - onResponderStateUpdated, }: EmbedFlowProps): ReactElement | null => { const containerRef = useRef(null); const eventListenersRef = useRef({}); @@ -229,7 +219,6 @@ export const EmbedFlow = ({ onFlowFinalized, onStepLoaded, onStepCompleted, - onResponderStateUpdated, }; const flowQueryParams = buildFlowQueryParams({ From eb86ff749d1aaf708db0ae2f4d976c9d64eeac2b Mon Sep 17 00:00:00 2001 From: Byrne Hollander Date: Wed, 29 Apr 2026 15:08:20 -0400 Subject: [PATCH 5/6] Fix ESM interop and closed-flow reload --- .../embed-messaging-manager/vite.config.mts | 3 - .../src/__tests__/EmbedFlow.test.tsx | 38 ++++++++++ packages/react-embed/src/index.tsx | 43 ++++++++--- packages/web-embed-api/src/index.ts | 75 +++++++++++++++++-- 4 files changed, 141 insertions(+), 18 deletions(-) diff --git a/packages/embed-messaging-manager/vite.config.mts b/packages/embed-messaging-manager/vite.config.mts index 5b768904..b2e4db41 100644 --- a/packages/embed-messaging-manager/vite.config.mts +++ b/packages/embed-messaging-manager/vite.config.mts @@ -7,9 +7,6 @@ export default defineConfig({ formats: ['es'], fileName: () => 'index.mjs', }, - rollupOptions: { - external: ['@formsort/constants'], - }, outDir: 'lib/esm', }, }); diff --git a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx index 0dadf864..c05448a6 100644 --- a/packages/react-embed/src/__tests__/EmbedFlow.test.tsx +++ b/packages/react-embed/src/__tests__/EmbedFlow.test.tsx @@ -263,6 +263,44 @@ describe('EmbedFlow component', () => { expect(container.firstChild).toBeNull(); }); + test('loads a new flow after the previous flow has closed', () => { + const { container, rerender } = render( + + ); + + const flowClosedListener = getAddedListener( + addEventListenerMock, + SupportedAnalyticsEvent.FlowClosed + ); + const eventPayload = { + answers: {}, + responder: { + responderUuid: 'responder-uuid', + sessionUuid: 'session-uuid', + }, + variantRevisionUuid: 'variant-revision-uuid', + stepId: 'step-id', + stepIndex: 0, + answerSources: {}, + }; + + act(() => { + flowClosedListener(eventPayload); + }); + + expect(container.firstChild).toBeNull(); + + rerender(); + + expect(container.firstChild).not.toBeNull(); + expect(loadMock).toHaveBeenLastCalledWith( + 'test-client', + 'next-flow', + undefined, + undefined + ); + }); + test('loads flows with URL params without mutating props', () => { const uuid = 'b1c7d9c8-f4b0-4f3f-9fc3-abf32ae8a061'; const queryParams: Array<[string, string]> = [['name', 'Olivia']]; diff --git a/packages/react-embed/src/index.tsx b/packages/react-embed/src/index.tsx index e778c317..45da15ed 100644 --- a/packages/react-embed/src/index.tsx +++ b/packages/react-embed/src/index.tsx @@ -150,6 +150,27 @@ const getEmbedConfigKey = ( } }; +const getFlowLoadKey = ({ + clientLabel, + embedConfigKey, + flowLabel, + queryParamsKey, + variantLabel, +}: { + clientLabel: string; + embedConfigKey: string; + flowLabel: string; + queryParamsKey: string; + variantLabel?: string; +}): string => + JSON.stringify([ + clientLabel, + embedConfigKey, + flowLabel, + queryParamsKey, + variantLabel, + ]); + const attachEventListener = ( embed: IFormsortWebEmbed, eventName: K, @@ -209,7 +230,7 @@ export const EmbedFlow = ({ }: EmbedFlowProps): ReactElement | null => { const containerRef = useRef(null); const eventListenersRef = useRef({}); - const [flowClosed, setFlowClosed] = useState(false); + const [closedFlowKey, setClosedFlowKey] = useState(); eventListenersRef.current = { onUnauthorized, @@ -228,6 +249,14 @@ export const EmbedFlow = ({ }); const queryParamsKey = getQueryParamsKey(flowQueryParams); const embedConfigKey = getEmbedConfigKey(embedConfig); + const flowLoadKey = getFlowLoadKey({ + clientLabel, + embedConfigKey, + flowLabel, + queryParamsKey, + variantLabel, + }); + const flowClosed = closedFlowKey === flowLoadKey; useEffect(() => { const containerElement = containerRef.current; @@ -236,14 +265,14 @@ export const EmbedFlow = ({ return undefined; } - setFlowClosed(false); + setClosedFlowKey(undefined); const embed = FormsortWebEmbed(containerElement, embedConfig); const removeEventListeners = attachEventListeners( embed, eventListenersRef, () => { - setFlowClosed(true); + setClosedFlowKey(flowLoadKey); } ); @@ -255,13 +284,7 @@ export const EmbedFlow = ({ }); embed.unloadFlow(); }; - }, [ - clientLabel, - embedConfigKey, - flowLabel, - queryParamsKey, - variantLabel, - ]); + }, [flowLoadKey]); if (flowClosed) { return null; diff --git a/packages/web-embed-api/src/index.ts b/packages/web-embed-api/src/index.ts index d2e9c5c3..e9566fe7 100644 --- a/packages/web-embed-api/src/index.ts +++ b/packages/web-embed-api/src/index.ts @@ -1,7 +1,8 @@ -import EmbedMessagingManager, { - type IFormsortEmbedConfig, - type IEventMap, - SupportedAnalyticsEvent, +import * as EmbedMessagingManagerModule from '@formsort/embed-messaging-manager'; +import type { + IFormsortEmbedConfig, + IEventMap, + SupportedAnalyticsEvent as SupportedAnalyticsEventType, } from '@formsort/embed-messaging-manager'; import { getMessageSender } from './iframe-utils'; import { isLocalOrLegacyFlowOrigin } from './utils'; @@ -44,6 +45,70 @@ const DEFAULT_CONFIG: IFormsortWebEmbedConfig = { const DEFAULT_ALLOW = 'camera;'; +type SupportedAnalyticsEvent = SupportedAnalyticsEventType; + +const getEmbedMessagingManager = ( + moduleExport: typeof EmbedMessagingManagerModule +): typeof EmbedMessagingManagerModule.default => { + const defaultExport = moduleExport.default as unknown; + + if (typeof defaultExport === 'function') { + return defaultExport as typeof EmbedMessagingManagerModule.default; + } + + const nestedDefault = + defaultExport && typeof defaultExport === 'object' + ? (defaultExport as unknown as { + default?: typeof EmbedMessagingManagerModule.default; + }).default + : undefined; + + if (typeof nestedDefault === 'function') { + return nestedDefault; + } + + throw new Error( + 'Unable to load @formsort/embed-messaging-manager default export' + ); +}; + +const getSupportedAnalyticsEvent = ( + moduleExport: typeof EmbedMessagingManagerModule +): typeof EmbedMessagingManagerModule.SupportedAnalyticsEvent => { + const directExport = ( + moduleExport as unknown as { + SupportedAnalyticsEvent?: typeof EmbedMessagingManagerModule.SupportedAnalyticsEvent; + } + ).SupportedAnalyticsEvent; + + if (directExport) { + return directExport; + } + + const defaultExport = + moduleExport.default && + typeof (moduleExport.default as unknown) === 'object' + ? (moduleExport.default as unknown as { + SupportedAnalyticsEvent?: typeof EmbedMessagingManagerModule.SupportedAnalyticsEvent; + }) + : undefined; + + if (defaultExport?.SupportedAnalyticsEvent) { + return defaultExport.SupportedAnalyticsEvent; + } + + throw new Error( + 'Unable to load @formsort/embed-messaging-manager event exports' + ); +}; + +const EmbedMessagingManager = getEmbedMessagingManager( + EmbedMessagingManagerModule +); +const SupportedAnalyticsEvent = getSupportedAnalyticsEvent( + EmbedMessagingManagerModule +); + const FormsortWebEmbed = ( rootEl: HTMLElement, config: IFormsortWebEmbedConfig = DEFAULT_CONFIG @@ -179,6 +244,6 @@ const FormsortWebEmbed = ( export { IFormsortWebEmbed, IFormsortWebEmbedConfig, IEventMap }; -export { SupportedAnalyticsEvent } from '@formsort/embed-messaging-manager'; +export { SupportedAnalyticsEvent }; export default FormsortWebEmbed; From 4d5113fb4921d4f89a9593f187fdf70d2f6c4f5e Mon Sep 17 00:00:00 2001 From: Byrne Hollander Date: Wed, 29 Apr 2026 15:51:20 -0400 Subject: [PATCH 6/6] Document react embed upgrade path --- packages/react-embed/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/react-embed/README.md b/packages/react-embed/README.md index f424d4c5..ac136786 100644 --- a/packages/react-embed/README.md +++ b/packages/react-embed/README.md @@ -11,6 +11,20 @@ The package publishes both ESM and CommonJS entrypoints. Add `@formsort/react-embed` to your project by executing `yarn add @formsort/react-embed` or `npm install @formsort/react-embed`. +## Upgrading + +Documented root imports continue to work for both ESM and CommonJS consumers: + +```tsx +import EmbedFlow from '@formsort/react-embed'; +``` + +```js +const EmbedFlow = require('@formsort/react-embed').default; +``` + +This is a minor upgrade because the package output and React lifecycle behavior were modernized. `EmbedFlow` now reloads when load props such as `clientLabel`, `flowLabel`, `variantLabel`, `queryParams`, or `embedConfig` change. Deep imports from internal `lib/*` paths are not part of the public API and are not guaranteed. + ## Usage Here's an example of basic usage: