Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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}</>;
};