Skip to content

Commit 6f3130a

Browse files
committed
Fix IMC bugs when multiple apps loaded at the same time & prevent same view ID from loading again
1 parent 8a6720a commit 6f3130a

File tree

9 files changed

+132
-66
lines changed

9 files changed

+132
-66
lines changed

npm-packages/react-api/src/hooks/editor/use-snapshot-state.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,20 @@ export default function useSnapShotState<T>(
2020
);
2121

2222
// Update context whenever state changes
23-
const setSnapshotState = (value: T | ((prev: T) => T)) => {
23+
const setSnapshotState: React.Dispatch<React.SetStateAction<T>> = (value) => {
2424
setState((prev) => {
25-
const next =
25+
const newValue =
2626
typeof value === "function" ? (value as (prev: T) => T)(prev) : value;
27-
setStates((prevStates) => ({
28-
...prevStates,
29-
[key]: next,
30-
}));
31-
return next;
27+
28+
// Defer the setStates call to next microtask, outside render phase
29+
Promise.resolve().then(() => {
30+
setStates((prevStates) => ({
31+
...prevStates,
32+
[key]: newValue,
33+
}));
34+
});
35+
36+
return newValue;
3237
});
3338
};
3439

npm-packages/react-api/src/lib/use-imc.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { InterModuleCommunication } from "@pulse-editor/shared-utils";
21
import {
32
IMCMessageTypeEnum,
3+
InterModuleCommunication,
44
ReceiverHandlerMap,
55
} from "@pulse-editor/shared-utils";
66
import { useEffect, useState } from "react";
@@ -35,9 +35,8 @@ export default function useIMC(handlerMap: ReceiverHandlerMap) {
3535
await newImc.initOtherWindow(targetWindow);
3636
setImc(newImc);
3737

38-
newImc.sendMessage(IMCMessageTypeEnum.AppReady).then(() => {
39-
setIsReady(true);
40-
});
38+
await newImc.sendMessage(IMCMessageTypeEnum.AppReady);
39+
setIsReady(true);
4140
}
4241

4342
initIMC();

npm-packages/shared-utils/src/imc/poly-imc.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,26 @@ export class ConnectionListener {
145145

146146
private listener: InterModuleCommunication;
147147

148+
/**
149+
*
150+
* @param polyIMC The polyIMC instance.
151+
* @param newConnectionReceiverHandlerMap Receiver handler map for newly established poly-IMC channel.
152+
* @param onConnection Callback function to be called when a new connection is established.
153+
* @param expectedOtherWindowId Optional expected other window ID to validate incoming connections.
154+
*/
148155
constructor(
149156
polyIMC: PolyIMC,
150157
newConnectionReceiverHandlerMap: ReceiverHandlerMap,
151-
onConnection?: (senderWindow: Window, message: IMCMessage) => void
158+
onConnection?: (senderWindow: Window, message: IMCMessage) => void,
159+
expectedOtherWindowId?: string
152160
) {
153161
this.polyIMC = polyIMC;
154162
this.newConnectionReceiverHandlerMap = newConnectionReceiverHandlerMap;
155163
this.onConnection = onConnection;
156164

157165
const listener = new InterModuleCommunication();
158166
this.listener = listener;
159-
listener.initThisWindow(window);
167+
listener.initThisWindow(window, expectedOtherWindowId);
160168

161169
listener.updateReceiverHandlerMap(
162170
new Map<IMCMessageTypeEnum, ReceiverHandler>([

web/components/app-loaders/sandbox-app-loader.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export default function SandboxAppLoader({
113113

114114
if (currentViewId) {
115115
// Listen for an incoming extension connection
116-
console.log("Listening for extension connection...");
116+
console.log(`[${currentViewId}]: Listening for app connection...`);
117117
listenForExtensionConnection();
118118

119119
setIsLookingForExtension(true);
@@ -136,6 +136,8 @@ export default function SandboxAppLoader({
136136
// When IMC is connected, remove the connection listener
137137
useEffect(() => {
138138
if (isConnected && clRef.current) {
139+
console.log(`[${currentViewId}]: App connected.`);
140+
// Close the connection listener
139141
clRef.current.close();
140142
clRef.current = null;
141143
}
@@ -184,6 +186,7 @@ export default function SandboxAppLoader({
184186
}: {
185187
isLoading: boolean;
186188
} = message.payload;
189+
console.log(`[${model.viewId}]: App is loading: `, isLoading);
187190
setIsLoadingExtension((prev) => isLoading);
188191
if (onInitialLoaded) {
189192
onInitialLoaded();
@@ -296,6 +299,7 @@ export default function SandboxAppLoader({
296299
(senderWindow: Window, message: IMCMessage) => {
297300
setIsConnected((prev) => true);
298301
},
302+
viewModel.viewId,
299303
);
300304
clRef.current = cl;
301305
}

web/components/marketplace/workflow/workflow-preview-card.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,7 @@ export default function WorkflowPreviewCard({
105105
color="primary"
106106
size="sm"
107107
onPress={() => {
108-
if (isPressable) {
109-
openWorkflow();
110-
}
108+
openWorkflow();
111109
}}
112110
>
113111
Use

web/components/modals/publish-workflow-modal.tsx

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,39 +29,50 @@ export default function PublishWorkflowModal({
2929
const [version, setVersion] = useState("");
3030

3131
async function publishWorkflow() {
32-
if (!workflowCanvas) {
33-
console.error("Workflow canvas is not available");
34-
return;
35-
}
32+
try {
33+
if (!workflowCanvas) {
34+
console.error("Workflow canvas is not available");
35+
return;
36+
}
3637

37-
setIsOpen(false);
38+
setIsOpen(false);
3839

39-
const res = await captureWorkflowCanvas(workflowCanvas);
40-
const dataUrl = res.toDataURL("image/png");
40+
const res = await captureWorkflowCanvas(workflowCanvas);
41+
const dataUrl = res.toDataURL("image/png");
4142

42-
const workflow: Workflow = {
43-
name: name,
44-
thumbnail: dataUrl,
45-
content: {
46-
nodes: localNodes ?? [],
47-
edges: localEdges ?? [],
48-
defaultEntryPoint: entryPoint,
49-
snapshotStates: await saveAppsSnapshotStates(),
50-
},
51-
version: version,
52-
visibility: "public",
53-
};
43+
const snapshotStates = await saveAppsSnapshotStates();
5444

55-
await fetchAPI("/api/workflow/publish", {
56-
method: "POST",
57-
body: JSON.stringify({ ...workflow }),
58-
});
45+
const workflow: Workflow = {
46+
name: name,
47+
thumbnail: dataUrl,
48+
content: {
49+
nodes: localNodes ?? [],
50+
edges: localEdges ?? [],
51+
defaultEntryPoint: entryPoint,
52+
snapshotStates: snapshotStates,
53+
},
54+
version: version,
55+
visibility: "public",
56+
};
5957

60-
addToast({
61-
title: "Workflow Published",
62-
description: "Your workflow has been published successfully.",
63-
color: "success",
64-
});
58+
await fetchAPI("/api/workflow/publish", {
59+
method: "POST",
60+
body: JSON.stringify({ ...workflow }),
61+
});
62+
63+
addToast({
64+
title: "Workflow Published",
65+
description: "Your workflow has been published successfully.",
66+
color: "success",
67+
});
68+
} catch (error) {
69+
console.error("Error publishing workflow:", error);
70+
addToast({
71+
title: "Error",
72+
description: "There was an error publishing your workflow.",
73+
color: "danger",
74+
});
75+
}
6576
}
6677

6778
async function handlePress() {

web/components/providers/imc-provider.tsx

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
STTConfig,
2828
TTSConfig,
2929
} from "@pulse-editor/shared-utils";
30-
import { createContext, useContext, useEffect, useRef, useState } from "react";
30+
import { createContext, useContext, useEffect, useRef } from "react";
3131
import { EditorContext } from "./editor-context-provider";
3232

3333
export const IMCContext = createContext<IMCContextType | undefined>(undefined);
@@ -39,7 +39,7 @@ export default function InterModuleCommunicationProvider({
3939
}) {
4040
const editorContext = useContext(EditorContext);
4141

42-
const [polyIMC, setPolyIMC] = useState<PolyIMC | undefined>(undefined);
42+
const polyIMCRef = useRef<PolyIMC | undefined>(undefined);
4343
const imcInitializedMapRef = useRef<Map<string, boolean>>(new Map());
4444
const imcInitializedResolvePromisesRef = useRef<{
4545
[key: string]: () => void;
@@ -53,29 +53,23 @@ export default function InterModuleCommunicationProvider({
5353
useEffect(() => {
5454
// @ts-expect-error set window viewId
5555
window.viewId = "Pulse Editor Main";
56+
polyIMCRef.current = new PolyIMC(getHandlerMap());
5657

5758
return () => {
5859
// Cleanup the polyIMC instance when the component unmounts
59-
if (polyIMC) {
60-
polyIMC.close();
61-
setPolyIMC(undefined);
60+
if (polyIMCRef) {
61+
polyIMCRef.current?.close();
62+
polyIMCRef.current = undefined;
6263
}
6364
};
6465
}, []);
6566

66-
useEffect(() => {
67-
if (!polyIMC) {
68-
const newPolyIMC = new PolyIMC(getHandlerMap());
69-
setPolyIMC(newPolyIMC);
70-
}
71-
}, [polyIMC, setPolyIMC]);
72-
7367
// Update the base handler map as editor context changes
7468
useEffect(() => {
75-
if (polyIMC) {
76-
polyIMC.updateBaseReceiverHandlerMap(getHandlerMap());
69+
if (polyIMCRef.current) {
70+
polyIMCRef.current?.updateBaseReceiverHandlerMap(getHandlerMap());
7771
}
78-
}, [polyIMC, editorContext]);
72+
}, [editorContext]);
7973

8074
function markIMCInitialized(viewId: string) {
8175
imcInitializedMapRef.current.set(viewId, true);
@@ -489,7 +483,7 @@ export default function InterModuleCommunicationProvider({
489483
return (
490484
<IMCContext.Provider
491485
value={{
492-
polyIMC,
486+
polyIMC: polyIMCRef.current,
493487
resolveWhenViewInitialized,
494488
markIMCInitialized,
495489
resolveWhenActionRegistered,

web/lib/hooks/use-canvas-workflow.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,12 +385,18 @@ export default function useCanvasWorkflow(
385385
const appStates = await Promise.all(
386386
apps.map(async (app) => {
387387
if (!app.viewId) return null;
388-
const { states } = await imcContext?.polyIMC?.sendMessage(
389-
app.viewId,
390-
IMCMessageTypeEnum.EditorAppStateSnapshotSave,
391-
);
392388

393-
return { appId: app.viewId, states: states };
389+
// Do a time out because the app may not use snapshot feature
390+
return await Promise.race([
391+
new Promise<any>((resolve) => setTimeout(() => resolve(null), 2000)),
392+
(async () => {
393+
const { states } = await imcContext?.polyIMC?.sendMessage(
394+
app.viewId,
395+
IMCMessageTypeEnum.EditorAppStateSnapshotSave,
396+
);
397+
return { appId: app.viewId, states: states };
398+
})(),
399+
]);
394400
}),
395401
);
396402

web/lib/hooks/use-tab-view-manager.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EditorContext } from "@/components/providers/editor-context-provider";
22
import { IMCContext } from "@/components/providers/imc-provider";
3+
import { addToast } from "@heroui/react";
34
import { ViewModeEnum } from "@pulse-editor/shared-utils";
45
import { useContext, useEffect, useState } from "react";
56
import { v4 } from "uuid";
@@ -206,6 +207,15 @@ export function useTabViewManager() {
206207
tabIndex: newIdx,
207208
};
208209
});
210+
211+
if (view.type === ViewModeEnum.Canvas) {
212+
// Remove the app nodes' view IDs from IMC context
213+
(view.config as CanvasViewConfig).appConfigs?.forEach((appConfig) => {
214+
imcContext?.polyIMC?.removeChannel(appConfig.viewId);
215+
});
216+
} else if (view.type === ViewModeEnum.App) {
217+
imcContext?.polyIMC?.removeChannel((view.config as AppViewConfig).viewId);
218+
}
209219
}
210220

211221
/**
@@ -222,6 +232,19 @@ export function useTabViewManager() {
222232
tabIndex: -1,
223233
};
224234
});
235+
236+
// For all tab views, remove their view IDs from IMC context
237+
tabViews.forEach((view) => {
238+
if (view.type === ViewModeEnum.Canvas) {
239+
(view.config as CanvasViewConfig).appConfigs?.forEach((appConfig) => {
240+
imcContext?.polyIMC?.removeChannel(appConfig.viewId);
241+
});
242+
} else if (view.type === ViewModeEnum.App) {
243+
imcContext?.polyIMC?.removeChannel(
244+
(view.config as AppViewConfig).viewId,
245+
);
246+
}
247+
});
225248
}
226249

227250
function viewCount(): number {
@@ -265,6 +288,20 @@ export function useTabViewManager() {
265288
throw new Error("IMC context is not available");
266289
}
267290

291+
// Prohibit creating canvas if any app's view ID in the canvas already exists
292+
const existViewId = canvasConfig.appConfigs?.find((appConfig) =>
293+
imcContext?.polyIMC?.hasChannel(appConfig.viewId),
294+
);
295+
296+
if (existViewId) {
297+
addToast({
298+
title: "Error creating canvas",
299+
description: `Same app nodes already exist. Your workflow might already be opened in another tab.`,
300+
color: "danger",
301+
});
302+
return undefined;
303+
}
304+
268305
const newTabView: TabView = {
269306
type: ViewModeEnum.Canvas,
270307
config: canvasConfig,
@@ -297,6 +334,10 @@ export function useTabViewManager() {
297334
currentTab = await createCanvasTabView({
298335
viewId: `canvas-${v4()}`,
299336
} as CanvasViewConfig);
337+
if (!currentTab) {
338+
console.error("Failed to create a new canvas tab");
339+
return;
340+
}
300341
}
301342

302343
const newCanvasConfig: CanvasViewConfig = {

0 commit comments

Comments
 (0)