diff --git a/e2e/build-bundle.js b/e2e/build-bundle.js index bc21672a..9015af21 100644 --- a/e2e/build-bundle.js +++ b/e2e/build-bundle.js @@ -18,7 +18,7 @@ const cssPlugin = { }, }; -esbuild +const baseBundle = esbuild .build({ entryPoints: [path.join(__dirname, "entry.ts")], bundle: true, @@ -37,3 +37,26 @@ esbuild console.error("E2E bundle failed:", err); process.exit(1); }); + +const reactBundle = esbuild + .build({ + entryPoints: [path.join(__dirname, "react-entry.ts")], + bundle: true, + outfile: path.join(__dirname, "dist/graph-react.bundle.js"), + format: "iife", + globalName: "GraphModule", + platform: "browser", + target: ["es2020"], + sourcemap: true, + plugins: [cssPlugin], + jsx: "automatic", + }) + .then(() => { + console.log("E2E React bundle created successfully with CSS"); + }) + .catch((err) => { + console.error("E2E React bundle failed:", err); + process.exit(1); + }); + +Promise.all([baseBundle, reactBundle]).catch(() => process.exit(1)); diff --git a/e2e/page-objects/GraphPageObject.ts b/e2e/page-objects/GraphPageObject.ts index fb54f5f8..4b129496 100644 --- a/e2e/page-objects/GraphPageObject.ts +++ b/e2e/page-objects/GraphPageObject.ts @@ -101,17 +101,18 @@ export class GraphPageObject { } /** - * Initialize graph with config + * Returns the URL of the HTML page to navigate to for initialization. + * Override in subclasses to use a different page (e.g. /react.html). */ - async initialize(config: GraphConfig): Promise { - await this.page.goto("/base.html"); - - // Wait for Graph library to load from the HTML page - await this.page.waitForFunction(() => { - return (window as any).graphLibraryLoaded === true; - }); + protected getUrl(): string { + return "/base.html"; + } - // Initialize graph using the loaded module + /** + * Creates and configures the graph instance in the browser context. + * Override in subclasses to use a different rendering setup (e.g. React). + */ + protected async setupGraph(config: GraphConfig): Promise { await this.page.evaluate((cfg) => { const rootEl = document.getElementById("root"); if (!rootEl) { @@ -137,6 +138,20 @@ export class GraphPageObject { window.graph = graph; window.graphInitialized = true; }, config); + } + + /** + * Initialize graph with config + */ + async initialize(config: GraphConfig): Promise { + await this.page.goto(this.getUrl()); + + // Wait for Graph library to load from the HTML page + await this.page.waitForFunction(() => { + return (window as any).graphLibraryLoaded === true; + }); + + await this.setupGraph(config); // Wait for graph to be ready await this.page.waitForFunction( @@ -148,6 +163,15 @@ export class GraphPageObject { await this.waitForFrames(3); } + /** + * Set camera zoom to a specific scale level + */ + async setZoom(scale: number): Promise { + await this.page.evaluate((s) => { + window.graph.zoom({ scale: s }); + }, scale); + } + /** * Wait for N animation frames to complete using graph's scheduler * This is necessary because the library uses Scheduler with requestAnimationFrame @@ -483,4 +507,16 @@ export class GraphPageObject { return new GraphEventListener(this.page, key); } + + /** + * Call setEntities on the graph with new blocks and connections + */ + async setEntities(config: GraphConfig): Promise { + await this.page.evaluate((cfg) => { + window.graph.setEntities({ + blocks: cfg.blocks || [], + connections: cfg.connections || [], + }); + }, config); + } } diff --git a/e2e/page-objects/ReactGraphPageObject.ts b/e2e/page-objects/ReactGraphPageObject.ts new file mode 100644 index 00000000..3383de44 --- /dev/null +++ b/e2e/page-objects/ReactGraphPageObject.ts @@ -0,0 +1,98 @@ +import { Page } from "@playwright/test"; +import { TBlock } from "../../src/components/canvas/blocks/Block"; +import { TConnection } from "../../src/store/connection/ConnectionState"; +import { GraphPageObject, GraphConfig } from "./GraphPageObject"; + +/** + * PageObject for React-based graph rendering (using GraphCanvas + BlocksList). + * Extends GraphPageObject with React-specific initialization. + */ +export class ReactGraphPageObject extends GraphPageObject { + constructor(page: Page) { + super(page); + } + + protected getUrl(): string { + return "/react.html"; + } + + /** + * Creates graph wrapped in React GraphCanvas (enables HTML block rendering via BlocksList). + */ + protected async setupGraph(config: GraphConfig): Promise { + await this.page.evaluate((cfg) => { + const { Graph, GraphCanvas, GraphBlock, React, ReactDOM } = (window as any).GraphModule; + + const rootEl = document.getElementById("root"); + if (!rootEl) { + throw new Error("Root element not found"); + } + + const graph = new Graph(cfg, rootEl); + + // Render with React and GraphCanvas (enables HTML block rendering via BlocksList) + const reactRoot = ReactDOM.createRoot(rootEl); + + const renderBlock = (g: unknown, block: { id: string; name?: string }) => { + return React.createElement( + GraphBlock, + { graph: g, block }, + React.createElement("div", { "data-testid": `block-${block.id}`, style: { padding: "8px" } }, block.name || block.id) + ); + }; + + reactRoot.render( + React.createElement(GraphCanvas, { + graph, + renderBlock, + style: { width: "100%", height: "100vh" }, + }) + ); + + // Set initial entities if provided + if (cfg.blocks || cfg.connections) { + graph.setEntities({ + blocks: cfg.blocks || [], + connections: cfg.connections || [], + }); + } + + graph.start(); + graph.zoomTo("center"); + + // Expose to window for tests + window.graph = graph; + window.graphInitialized = true; + }, config); + } + + /** + * Get count of rendered HTML blocks in the DOM + */ + async getRenderedHtmlBlockCount(): Promise { + return await this.page.evaluate(() => { + return document.querySelectorAll("[data-testid^='block-']").length; + }); + } + + /** + * Get IDs of rendered HTML blocks in the DOM + */ + async getRenderedHtmlBlockIds(): Promise { + return await this.page.evaluate(() => { + const elements = document.querySelectorAll("[data-testid^='block-']"); + return Array.from(elements).map((el) => + el.getAttribute("data-testid")?.replace("block-", "") || "" + ); + }); + } + + /** + * Check if a specific HTML block is rendered in the DOM + */ + async isHtmlBlockRendered(blockId: string): Promise { + return await this.page.evaluate((id) => { + return !!document.querySelector(`[data-testid='block-${id}']`); + }, blockId); + } +} diff --git a/e2e/pages/react.html b/e2e/pages/react.html new file mode 100644 index 00000000..a81cc8df --- /dev/null +++ b/e2e/pages/react.html @@ -0,0 +1,26 @@ + + + + + Graph React E2E Test + + + +
+ + + + diff --git a/e2e/react-entry.ts b/e2e/react-entry.ts new file mode 100644 index 00000000..d458a2d7 --- /dev/null +++ b/e2e/react-entry.ts @@ -0,0 +1,13 @@ +// React E2E bundle entry point +import React from "react"; +import ReactDOM from "react-dom/client"; + +import "../src/services/Layer.css"; +import "../src/react-components/graph-canvas.css"; +import "../src/react-components/Block.css"; +import "../src/react-components/Anchor.css"; + +// Re-export everything from main and react indexes +export * from "../src/index"; +export * from "../src/react-components/index"; +export { React, ReactDOM }; diff --git a/e2e/tests/reload-test.spec.ts b/e2e/tests/reload-test.spec.ts new file mode 100644 index 00000000..09f6e972 --- /dev/null +++ b/e2e/tests/reload-test.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "@playwright/test"; +import { ReactGraphPageObject } from "../page-objects/ReactGraphPageObject"; +import { TBlock } from "../../src/components/canvas/blocks/Block"; + +const blocks: TBlock[] = [ + { id: "b1", is: "Block", x: 100, y: 100, width: 200, height: 100, name: "B1", anchors: [], selected: false }, + { id: "b2", is: "Block", x: 400, y: 100, width: 200, height: 100, name: "B2", anchors: [], selected: false }, +]; + +test("Issue #249: html blocks disappear after reload with double setEntities", async ({ page }) => { + const graphPage = new ReactGraphPageObject(page); + await graphPage.initialize({ blocks, connections: [] }); + await graphPage.setZoom(1.0); + await graphPage.waitForFrames(10); + + // Simulate "reload" — double setEntities + await graphPage.setEntities({ blocks: [], connections: [] }); + await graphPage.setEntities({ blocks, connections: [] }); + await graphPage.waitForFrames(20); + + expect(await graphPage.getRenderedHtmlBlockCount()).toBe(2); +}); diff --git a/e2e/tests/selection-test.spec.ts b/e2e/tests/selection-test.spec.ts index edece3fb..79241802 100644 --- a/e2e/tests/selection-test.spec.ts +++ b/e2e/tests/selection-test.spec.ts @@ -25,16 +25,10 @@ test.describe("Selection Test", () => { // Try selecting programmatically first await page.evaluate(() => { const blockState = window.graph.blocks.getBlockState("block-1"); - console.log("BlockState exists:", !!blockState); - console.log("SelectionService:", window.graph.selectionService); // Try to select the block using correct API const { ESelectionStrategy } = window.GraphModule; - window.graph.selectionService.select( - "block", - ["block-1"], - ESelectionStrategy.REPLACE - ); + window.graph.selectionService.select("block", ["block-1"], ESelectionStrategy.REPLACE); }); await page.waitForTimeout(200); diff --git a/e2e/tests/set-entities.spec.ts b/e2e/tests/set-entities.spec.ts new file mode 100644 index 00000000..3afb5f7c --- /dev/null +++ b/e2e/tests/set-entities.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from "@playwright/test"; +import { ReactGraphPageObject } from "../page-objects/ReactGraphPageObject"; + +const BLOCKS_DATA = [ + { + id: "block-1", + is: "Block" as const, + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, + }, + { + id: "block-2", + is: "Block" as const, + x: 400, + y: 100, + width: 200, + height: 100, + name: "Block 2", + anchors: [], + selected: false, + }, + { + id: "block-3", + is: "Block" as const, + x: 250, + y: 300, + width: 200, + height: 100, + name: "Block 3", + anchors: [], + selected: false, + }, +]; + +test.describe("setEntities - HTML blocks rendering (issue #249)", () => { + let graphPO: ReactGraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new ReactGraphPageObject(page); + + // Initialize with empty graph at "Detailed" zoom level (scale >= 0.7) + // so HTML blocks (ReactLayer / BlocksList) are rendered + await graphPO.initialize({ + blocks: [], + connections: [], + }); + + // Zoom in to "Detailed" scale level (>= 0.7) to activate HTML block rendering + const camera = graphPO.getCamera(); + await camera.zoomToScale(1.0); + }); + + test("should render HTML blocks after initial setEntities", async () => { + await graphPO.setEntities({ blocks: BLOCKS_DATA, connections: [] }); + await graphPO.waitForFrames(5); + + const count = await graphPO.getRenderedHtmlBlockCount(); + expect(count).toBe(3); + }); + + test("should render HTML blocks after setEntities with empty then full data (issue #249)", async () => { + // This is the exact scenario from the issue: + // calling setEntities twice rapidly - first clear, then populate + await graphPO.page.evaluate((blocks) => { + // Call both setEntities back-to-back with no await between them + window.graph.setEntities({ blocks: [], connections: [] }); + window.graph.setEntities({ blocks, connections: [] }); + }, BLOCKS_DATA); + + // Wait enough frames for the rendering to complete + await graphPO.waitForFrames(10); + + // All 3 HTML blocks should be visible + const count = await graphPO.getRenderedHtmlBlockCount(); + expect(count).toBe(3); + + const ids = await graphPO.getRenderedHtmlBlockIds(); + expect(ids).toContain("block-1"); + expect(ids).toContain("block-2"); + expect(ids).toContain("block-3"); + }); + + test("should render HTML blocks after setEntities with small delay between calls", async () => { + // As described in the issue, even a small timeout may not prevent the problem + await graphPO.page.evaluate(async (blocks) => { + window.graph.setEntities({ blocks: [], connections: [] }); + + // Small synchronous delay (setTimeout 0 - one tick) + await new Promise((resolve) => setTimeout(resolve, 0)); + + window.graph.setEntities({ blocks, connections: [] }); + }, BLOCKS_DATA); + + await graphPO.waitForFrames(10); + + const count = await graphPO.getRenderedHtmlBlockCount(); + expect(count).toBe(3); + }); + + test("should update HTML blocks when setEntities replaces blocks with different data", async () => { + // Set initial blocks + await graphPO.setEntities({ blocks: BLOCKS_DATA, connections: [] }); + await graphPO.waitForFrames(5); + + expect(await graphPO.getRenderedHtmlBlockCount()).toBe(3); + + // Replace with different blocks + const newBlocks = [ + { + id: "new-block-1", + is: "Block" as const, + x: 200, + y: 200, + width: 200, + height: 100, + name: "New Block 1", + anchors: [], + selected: false, + }, + ]; + + await graphPO.page.evaluate((blocks) => { + window.graph.setEntities({ blocks: [], connections: [] }); + window.graph.setEntities({ blocks, connections: [] }); + }, newBlocks); + + await graphPO.waitForFrames(10); + + const count = await graphPO.getRenderedHtmlBlockCount(); + expect(count).toBe(1); + + const rendered = await graphPO.isHtmlBlockRendered("new-block-1"); + expect(rendered).toBe(true); + }); +}); diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index f3a7875f..9f5026ca 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -106,6 +106,8 @@ export class Block; + private connectedStateUnsubscribers: (() => void)[] = []; + protected lastDragEvent?: MouseEvent; protected startDragCoords: number[] = []; @@ -171,6 +173,9 @@ export class Block unsub()); + this.connectedStateUnsubscribers = []; + this.connectedState = selectBlockById(this.context.graph, id); this.state = cloneDeep(this.connectedState.$state.value); this.connectedState.setViewComponent(this); @@ -183,15 +188,14 @@ export class Block { + this.connectedStateUnsubscribers = [ + this.connectedState.$anchors.subscribe(() => { this.setState({ anchors: this.connectedState.$anchors.value, }); this.shouldUpdateChildren = true; }), - this.subscribeSignal(this.connectedState.$state, () => { + this.connectedState.$state.subscribe(() => { this.setState({ ...this.connectedState.$state.value, anchors: this.connectedState.$anchors.value, @@ -202,6 +206,14 @@ export class Block unsub()); + this.connectedStateUnsubscribers = []; + // Release ownership of all ports owned by this block const connectionsList = this.context.graph.rootStore.connectionsList; diff --git a/src/react-components/Block.tsx b/src/react-components/Block.tsx index 9fad4ea8..b7994dd8 100644 --- a/src/react-components/Block.tsx +++ b/src/react-components/Block.tsx @@ -5,7 +5,7 @@ import { noop } from "lodash"; import { TBlock } from "../components/canvas/blocks/Block"; import { Graph } from "../graph"; -import { useSignalEffect } from "./hooks"; +import { useComputedSignal, useSignalEffect } from "./hooks"; import { useBlockState } from "./hooks/useBlockState"; import { cn } from "./utils/cn"; @@ -97,7 +97,7 @@ export const GraphBlock = ({ const lastStateRef = useRef({ x: 0, y: 0, width: 0, height: 0, zIndex: 0 }); const state = useBlockState(graph, block); - const viewState = state?.getViewComponent(); + const viewState = useComputedSignal(() => state?.$viewComponent.value, [state]); const [interactive, setInteractive] = useState(viewState?.isInteractive() ?? false); /** diff --git a/src/store/block/Block.ts b/src/store/block/Block.ts index f4e46f84..eaf15ddb 100644 --- a/src/store/block/Block.ts +++ b/src/store/block/Block.ts @@ -138,7 +138,7 @@ export class BlockState { ); }); - private blockView: Block; + public readonly $viewComponent = signal(undefined); constructor( public readonly store: BlockListStore, @@ -168,16 +168,16 @@ export class BlockState { public updateXY(x: number, y: number, forceUpdate = false) { this.store.updatePosition(this.id, { x, y }); if (forceUpdate) { - this.blockView.updatePosition(x, y, true); + this.$viewComponent.value?.updatePosition(x, y, true); } } public setViewComponent(blockComponent: Block) { - this.blockView = blockComponent; + this.$viewComponent.value = blockComponent; } public getViewComponent() { - return this.blockView; + return this.$viewComponent.value; } public getConnections() {