diff --git a/.changeset/strong-crews-fry.md b/.changeset/strong-crews-fry.md new file mode 100644 index 000000000..0517b4ad1 --- /dev/null +++ b/.changeset/strong-crews-fry.md @@ -0,0 +1,5 @@ +--- +"@ensembleui/react-runtime": patch +--- + +enable widget's OnLoadAction to handle storage-bound input bindings and prevent premature rendering diff --git a/packages/runtime/src/runtime/__tests__/customWidget.test.tsx b/packages/runtime/src/runtime/__tests__/customWidget.test.tsx index 7fa31a73e..500092497 100644 --- a/packages/runtime/src/runtime/__tests__/customWidget.test.tsx +++ b/packages/runtime/src/runtime/__tests__/customWidget.test.tsx @@ -47,6 +47,43 @@ describe("Custom Widget", () => { }, }), ); + + // register a widget that tests storage timing in onLoad + WidgetRegistry.register( + "StorageTestWidget", + createCustomWidget({ + name: "StorageTestWidget", + inputs: ["userFilters"], + onLoad: { + executeCode: ` + console.log('userFilters in onLoad:', userFilters); + console.log('userFilters type:', typeof userFilters); + console.log('userFilters JSON:', JSON.stringify(userFilters)); + `, + }, + body: { + name: "Text", + properties: { + // eslint-disable-next-line no-template-curly-in-string + text: "UserFilters: ${JSON.stringify(userFilters)}", + id: "userFiltersText", + }, + }, + }), + ); + }); + + beforeEach(() => { + sessionStorage.setItem( + "ensemble.storage", + JSON.stringify({ + userFilters: { status: "active", priority: "high" }, + }), + ); + }); + + afterEach(() => { + sessionStorage.clear(); }); it("renders custom widget with unspecified inputs", async () => { @@ -126,4 +163,109 @@ describe("Custom Widget", () => { expect(screen.queryByText("goodbye")).toBeNull(); }); }); + + it("onLoad action has access to widget inputs bound to storage", async () => { + const logSpy = jest.spyOn(console, "log"); + + render( + , + { + wrapper: BrowserRouter, + }, + ); + + // wait for the component to render and onLoad to execute + await waitFor(() => { + // verify console logs show userFilters was correctly evaluated + expect(logSpy).toHaveBeenCalledWith("userFilters in onLoad:", { + status: "active", + priority: "high", + }); + expect(logSpy).toHaveBeenCalledWith("userFilters type:", "object"); + expect(logSpy).toHaveBeenCalledWith( + "userFilters JSON:", + '{"status":"active","priority":"high"}', + ); + + const userFiltersText = screen.getByTestId("userFiltersText"); + + // verify that userFilters (widget input) got the correct storage value + expect(userFiltersText).toHaveTextContent( + 'UserFilters: {"status":"active","priority":"high"}', + ); + }); + }); + + it("onLoad action executes after widget inputs are properly evaluated", async () => { + const logSpy = jest.spyOn(console, "log"); + + // set storage data with different key to test the exact user scenario + sessionStorage.setItem( + "ensemble.storage", + JSON.stringify({ + userFilters: { category: "work", completed: false }, + }), + ); + + render( + , + { + wrapper: BrowserRouter, + }, + ); + + // initially, the widget content should NOT be visible because onLoad hasn't completed yet + expect(screen.queryByTestId("userFiltersText")).not.toBeInTheDocument(); + + // wait for storage hydration and onLoad execution + await waitFor(() => { + // verify console logs show userFilters was correctly evaluated with different data + expect(logSpy).toHaveBeenCalledWith("userFilters in onLoad:", { + category: "work", + completed: false, + }); + expect(logSpy).toHaveBeenCalledWith("userFilters type:", "object"); + expect(logSpy).toHaveBeenCalledWith( + "userFilters JSON:", + '{"category":"work","completed":false}', + ); + + const userFiltersText = screen.getByTestId("userFiltersText"); + + // widget input should have the correct storage data + expect(userFiltersText).toHaveTextContent( + 'UserFilters: {"category":"work","completed":false}', + ); + }); + }); }); diff --git a/packages/runtime/src/runtime/customWidget.tsx b/packages/runtime/src/runtime/customWidget.tsx index 7eecdd2c1..6dafbad01 100644 --- a/packages/runtime/src/runtime/customWidget.tsx +++ b/packages/runtime/src/runtime/customWidget.tsx @@ -9,6 +9,7 @@ import type { EnsembleAction, } from "@ensembleui/react-framework"; import React, { useEffect, useState } from "react"; +import { isEmpty, some, includes } from "lodash-es"; import { EnsembleRuntime } from "./runtime"; // FIXME: refactor // eslint-disable-next-line import/no-cycle @@ -40,13 +41,18 @@ export const createCustomWidget = ( return ( - + {EnsembleRuntime.render([widget.body])} ); }; + return CustomWidget; }; @@ -54,12 +60,34 @@ const OnLoadAction: React.FC< React.PropsWithChildren<{ action?: EnsembleAction; context: { [key: string]: unknown }; + rawInputs: { [key: string]: unknown }; }> -> = ({ action, children, context }) => { +> = ({ action, children, context, rawInputs }) => { const onLoadAction = useEnsembleAction(action); const [isComplete, setIsComplete] = useState(false); + // check if any inputs are bound to storage + const [areInputBindingsReady, setAreInputBindingsReady] = useState( + isEmpty(rawInputs) || + !some( + rawInputs, + (input) => + typeof input === "string" && includes(input, "ensemble.storage.get"), + ), + ); + + // wait for binding evaluation to complete on next tick + useEffect(() => { + if (areInputBindingsReady) { + return; + } + const timer = setTimeout(() => { + setAreInputBindingsReady(true); + }, 0); + return () => clearTimeout(timer); + }, [areInputBindingsReady]); + useEffect(() => { - if (!onLoadAction?.callback || isComplete) { + if (!onLoadAction?.callback || isComplete || !areInputBindingsReady) { return; } try { @@ -69,7 +97,14 @@ const OnLoadAction: React.FC< } finally { setIsComplete(true); } - }, [context, isComplete, onLoadAction?.callback]); + }, [context, isComplete, areInputBindingsReady, onLoadAction?.callback]); + + // don't render children until onLoad completes to prevent flash of unwanted content + // this ensures that if onLoad sets initial state (like hiding/showing elements), + // users won't see a brief flash of the default state before the onLoad logic runs + if (!isComplete && action) { + return null; + } return <>{children}; };