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}>;
};