Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strong-crews-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensembleui/react-runtime": patch
---

enable widget's OnLoadAction to handle storage-bound input bindings and prevent premature rendering
142 changes: 142 additions & 0 deletions packages/runtime/src/runtime/__tests__/customWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
<EnsembleScreen
screen={{
name: "test",
id: "test",
body: {
name: "StorageTestWidget",
properties: {
inputs: {
// This is the exact scenario from the user's issue
// eslint-disable-next-line no-template-curly-in-string
userFilters: "${ensemble.storage.get('userFilters') ?? {}}",
},
},
},
}}
/>,
{
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(
<EnsembleScreen
screen={{
name: "test",
id: "test",
body: {
name: "StorageTestWidget",
properties: {
inputs: {
// This matches the user's exact input binding
// eslint-disable-next-line no-template-curly-in-string
userFilters: "${ensemble.storage.get('userFilters') ?? {}}",
},
},
},
}}
/>,
{
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}',
);
});
});
});
43 changes: 39 additions & 4 deletions packages/runtime/src/runtime/customWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,26 +41,53 @@ export const createCustomWidget = (
return (
<CustomEventScopeProvider value={events}>
<CustomScopeProvider value={values ?? inputs}>
<OnLoadAction action={widget.onLoad} context={values ?? inputs}>
<OnLoadAction
action={widget.onLoad}
context={{ ...(values ?? inputs) }}
rawInputs={inputs}
>
{EnsembleRuntime.render([widget.body])}
</OnLoadAction>
</CustomScopeProvider>
</CustomEventScopeProvider>
);
};

return CustomWidget;
};

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 {
Expand All @@ -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}</>;
};