diff --git a/.changeset/strange-years-cry.md b/.changeset/strange-years-cry.md new file mode 100644 index 000000000..dd359733b --- /dev/null +++ b/.changeset/strange-years-cry.md @@ -0,0 +1,6 @@ +--- +"@ensembleui/react-kitchen-sink": patch +"@ensembleui/react-runtime": patch +--- + +prevent full app reload when using Button under Link widget diff --git a/apps/kitchen-sink/src/ensemble/screens/widgets.yaml b/apps/kitchen-sink/src/ensemble/screens/widgets.yaml index b420413ca..3e803c856 100644 --- a/apps/kitchen-sink/src/ensemble/screens/widgets.yaml +++ b/apps/kitchen-sink/src/ensemble/screens/widgets.yaml @@ -1331,8 +1331,8 @@ View: color: blue hoverColor: red widget: - Text: - text: Forms Page (with hover effect) + Button: + label: Forms Page (with hover effect) - Card: styles: diff --git a/packages/runtime/src/widgets/Link.tsx b/packages/runtime/src/widgets/Link.tsx index 3257f7a72..9d1e7a14f 100644 --- a/packages/runtime/src/widgets/Link.tsx +++ b/packages/runtime/src/widgets/Link.tsx @@ -1,7 +1,7 @@ import type { Expression, EnsembleAction } from "@ensembleui/react-framework"; import { useRegisterBindings, unwrapWidget } from "@ensembleui/react-framework"; import { useMemo, useCallback } from "react"; -import { Link as RouterLink } from "react-router-dom"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; import { cloneDeep } from "lodash-es"; import { WidgetRegistry } from "../registry"; import type { EnsembleWidgetProps } from "../shared/types"; @@ -41,6 +41,7 @@ export type LinkProps = { export const Link: React.FC = ({ id, onTap, widget, ...rest }) => { const action = useEnsembleAction(onTap); + const navigate = useNavigate(); const { values, rootRef } = useRegisterBindings({ ...rest, widgetName }, id); @@ -92,6 +93,25 @@ export const Link: React.FC = ({ id, onTap, widget, ...rest }) => { ); }, [values?.url]); + // Intercept normal left-clicks (no modifiers) during capture phase + // This ensures navigation is handled client-side even if the child (e.g., Button) + // calls stopPropagation in its onClick. Right/middle clicks remain native. + const onClickCapture = useCallback( + (e: React.MouseEvent) => { + if (!values?.url || isExternalUrl) return; + // only handle left click without modifiers + if (e.button !== 0) return; + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + + e.preventDefault(); + navigate(values.url, { + replace: Boolean(values.replace), + state: values.inputs, + }); + }, + [navigate, values?.url, values?.replace, values?.inputs, isExternalUrl], + ); + if (!values?.url) { return ( @@ -130,6 +150,7 @@ export const Link: React.FC = ({ id, onTap, widget, ...rest }) => { // For internal navigation, use React Router Link return ( { if (hoverStyles.color) e.currentTarget.style.color = hoverStyles.color; diff --git a/packages/runtime/src/widgets/__tests__/Link.test.tsx b/packages/runtime/src/widgets/__tests__/Link.test.tsx index 0e8a2988f..89b5e0124 100644 --- a/packages/runtime/src/widgets/__tests__/Link.test.tsx +++ b/packages/runtime/src/widgets/__tests__/Link.test.tsx @@ -443,6 +443,35 @@ describe("Link Widget", () => { consoleSpy.mockRestore(); }); + + test("navigates on left-click when Link wraps Button (capture-phase)", async () => { + let currentLocation: ReturnType; + + const LocationTracker = (): null => { + const location = useLocation(); + currentLocation = location; + return null; + }; + + render( + + + + , + ); + + const buttonText = screen.getByText("Open Link"); + fireEvent.click(buttonText, { button: 0 }); + + await waitFor(() => { + expect(currentLocation.pathname).toBe("/two/abc"); + }); + }); }); describe("Link Widget Integration Tests", () => {