Skip to content

Commit

Permalink
refactor(devtools): update element selection for the x-ray feature (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
aliemir committed Aug 5, 2024
1 parent 81703b6 commit 4e37590
Show file tree
Hide file tree
Showing 10 changed files with 307 additions and 307 deletions.
13 changes: 13 additions & 0 deletions .changeset/modern-panthers-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@refinedev/devtools": patch
---

refactor(devtools): check both parent and child nodes for representation

Previously, Refine Devtools's X-Ray feature looked for the representation of the components by looking at the parent nodes until a proper `stateNode` was found. This was problematic when the parent node was not a proper HTML element. A lack of type checking caused the feature to break in runtime in some cases.

Adding only a type check for the `stateNode` is not enough since there may be cases where there are no proper HTML elements in the parent nodes. This change adds a check for the child nodes as well. This way, the feature will look for the representation in both the parent and child nodes.

First check for a representation node will be done in the child nodes. If a proper representation is not found, an element will be searched in the parent nodes. If a no proper representation is found in the parent nodes, `document.body` will be used as the representation.

[Resolves #6219](https://github.com/refinedev/refine/issues/6219)
7 changes: 7 additions & 0 deletions .changeset/thirty-files-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@refinedev/devtools": patch
---

fix(devtools): styling issues in the X-Ray feature

A minimum size was set for the X-Ray feature's overlay to prevent it from being too small.
1 change: 1 addition & 0 deletions examples/base-material-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@mui/x-data-grid": "^6.6.0",
"@refinedev/cli": "^2.16.36",
"@refinedev/core": "^4.53.0",
"@refinedev/devtools": "^1.2.6",
"@refinedev/mui": "^5.19.0",
"@refinedev/react-hook-form": "^4.8.20",
"@refinedev/react-router-v6": "^4.5.11",
Expand Down
94 changes: 49 additions & 45 deletions examples/base-material-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import { DevtoolsProvider, DevtoolsPanel } from "@refinedev/devtools";
import { BrowserRouter, Routes, Route, Outlet } from "react-router-dom";

import { PostList, PostCreate, PostEdit, PostShow } from "../src/pages/posts";
Expand All @@ -26,55 +27,58 @@ const App: React.FC = () => {
<ThemeProvider theme={RefineThemes.Blue}>
<CssBaseline />
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider("https://api.fake-rest.refine.dev")}
notificationProvider={useNotificationProvider}
resources={[
{
name: "posts",
list: "/posts",
create: "/posts/create",
edit: "/posts/edit/:id",
show: "/posts/show/:id",
meta: {
canDelete: true,
<DevtoolsProvider>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider("https://api.fake-rest.refine.dev")}
notificationProvider={useNotificationProvider}
resources={[
{
name: "posts",
list: "/posts",
create: "/posts/create",
edit: "/posts/edit/:id",
show: "/posts/show/:id",
meta: {
canDelete: true,
},
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
<Routes>
<Route
index
element={<NavigateToResource resource="posts" />}
/>
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route
index
element={<NavigateToResource resource="posts" />}
/>

<Route path="/posts">
<Route index element={<PostList />} />
<Route path="create" element={<PostCreate />} />
<Route path="edit/:id" element={<PostEdit />} />
<Route path="show/:id" element={<PostShow />} />
</Route>
<Route path="/posts">
<Route index element={<PostList />} />
<Route path="create" element={<PostCreate />} />
<Route path="edit/:id" element={<PostEdit />} />
<Route path="show/:id" element={<PostShow />} />
</Route>

<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</RefineSnackbarProvider>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<DevtoolsPanel />
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</RefineSnackbarProvider>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
);
Expand Down
1 change: 1 addition & 0 deletions packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@types/jest": "^29.2.4",
"@types/lodash": "^4.14.171",
"@types/node": "^18.16.2",
"@types/react-reconciler": "^0.28.8",
"@types/testing-library__jest-dom": "^5.14.3",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
Expand Down
1 change: 1 addition & 0 deletions packages/devtools/src/components/devtools-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const DevtoolsSelector = ({
width: 100%;
transform: rotate(0deg);
transition: transform 0.2s ease-in-out;
line-height: 1;
}
.refine-devtools-selector-button:hover {
Expand Down
41 changes: 24 additions & 17 deletions packages/devtools/src/components/selectable-elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ import { createPortal } from "react-dom";
import { ApplyStyles } from "./apply-styles";
import { SelectorIcon } from "./icons/selector-button";

const MIN_SIZE = 22;

const getPosition = (element: HTMLElement, document: Document) => {
const { top, left, width, height } = element.getBoundingClientRect();
const { scrollLeft, scrollTop } = document.documentElement;
const positionLeft = left + scrollLeft - Math.max(0, MIN_SIZE - width) / 2;
const positionTop = top + scrollTop - Math.max(0, MIN_SIZE - height) / 2;

return {
left: positionLeft,
top: positionTop,
width: Math.max(MIN_SIZE, width),
height: Math.max(MIN_SIZE, height),
};
};

const SelectableElement = ({
element,
name,
Expand All @@ -13,30 +29,18 @@ const SelectableElement = ({
name: string;
onSelect: (name: string) => void;
}) => {
const [position] = React.useState(() => {
const { top, left, width, height } = element.getBoundingClientRect();
const { scrollLeft, scrollTop } = document.documentElement;
const positionLeft = left + scrollLeft;
const positionTop = top + scrollTop;

return { left: positionLeft, top: positionTop, width, height };
});
const [position] = React.useState(() => getPosition(element, document));

const elementRef = React.useRef<HTMLButtonElement | null>(null);

React.useEffect(() => {
// use scroll event listener
const onScroll = debounce(
() => {
const { top, left, width, height } = element.getBoundingClientRect();
const { scrollLeft, scrollTop } = document.documentElement;
const positionLeft = left + scrollLeft;
const positionTop = top + scrollTop;

elementRef.current?.style.setProperty("left", `${positionLeft}px`);
elementRef.current?.style.setProperty("top", `${positionTop}px`);
elementRef.current?.style.setProperty("width", `${width}px`);
elementRef.current?.style.setProperty("height", `${height}px`);
const nextPos = getPosition(element, document);
(["left", "top", "width", "height"] as const).forEach((prop) => {
elementRef.current?.style.setProperty(prop, `${nextPos[prop]}px`);
});
elementRef.current?.style.setProperty("opacity", "1");
},
200,
Expand Down Expand Up @@ -247,6 +251,9 @@ export const SelectableElements = ({
max-width: 200px;
padding-right: 8px;
}
.selector-xray-box:hover .selector-xray-info-title {
z-index: 90;
}
`
}
</ApplyStyles>
Expand Down
157 changes: 157 additions & 0 deletions packages/devtools/src/utilities/selector-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
getElementFromFiber,
getFiberFromElement,
getFirstFiberHasName,
getFirstStateNodeFiber,
getNameFromFiber,
getParentOfFiber,
} from "@aliemir/dom-to-fiber-utils";

type Fiber = Exclude<ReturnType<typeof getFiberFromElement>, null>;

export type SelectableElement = {
element: HTMLElement;
name: string;
};

const getChildOfFiber = (fiber: Fiber | null) => {
if (!fiber) {
return null;
}

return fiber.child;
};

const getFirstHTMLElementFromFiberByChild = (fiber: Fiber | null) => {
let child = fiber;

while (child) {
const element = getElementFromFiber(child);
if (element && element instanceof HTMLElement) {
return element;
}

child = getChildOfFiber(child) as Fiber;
}

return null;
};

const getFirstHTMLElementFromFiberByParent = (fiber: Fiber | null) => {
let parent = fiber;

while (parent) {
const element = getElementFromFiber(parent);
if (element && element instanceof HTMLElement) {
return element;
}

parent = getParentOfFiber(parent) as Fiber;
}

return null;
};

const getFirstHTMLElementFromFiber = (
fiber: Fiber | null,
): [element: HTMLElement, "child" | "parent" | "body"] => {
let element = getFirstHTMLElementFromFiberByChild(fiber);

if (element) {
return [element, "child"];
}

element = getFirstHTMLElementFromFiberByParent(fiber);

if (element) {
return [element, "parent"];
}

return [document.body, "body"];
};

const selectFiber = (start: Fiber | null, activeTraceItems: string[]) => {
let fiber = start;
let firstParentOfNodeWithName: Fiber | null = null;
let fiberWithStateNode: Fiber | null = null;

let acceptedName = false;

while (!acceptedName && fiber) {
// Get the first fiber node that has a name (look up the tree)
firstParentOfNodeWithName = getFirstFiberHasName(fiber);
// Get the first fiber node that has a state node (look up the tree)
fiberWithStateNode = getFirstStateNodeFiber(firstParentOfNodeWithName);
acceptedName = activeTraceItems.includes(
getNameFromFiber(firstParentOfNodeWithName) ?? "",
);
if (!acceptedName) {
fiber = getParentOfFiber(fiber);
}
}

if (fiberWithStateNode && firstParentOfNodeWithName) {
return {
stateNode: fiberWithStateNode,
nameFiber: firstParentOfNodeWithName,
};
}
return {
stateNode: null,
nameFiber: null,
};
};

export const filterInvisibleNodes = (nodes: SelectableElement[]) => {
return nodes.filter(
(item) => item.element.offsetWidth > 0 && item.element.offsetHeight > 0,
);
};

export const getUniqueNodes = (nodes: SelectableElement[]) => {
const uniques: SelectableElement[] = [];

nodes.forEach((node) => {
const isElementExist = uniques.find(
(item) => item.element === node.element && item.name === node.name,
);
if (!isElementExist) {
uniques.push(node);
}
});

return uniques;
};

export const traverseDom = (
node: HTMLElement | null,
activeTraceItems: string[],
): SelectableElement[] => {
if (!node) {
return [];
}

const items: SelectableElement[] = [];

const fiber = getFiberFromElement(node);
const targetFiber = selectFiber(fiber, activeTraceItems);

if (targetFiber.nameFiber) {
const [element] = getFirstHTMLElementFromFiber(targetFiber.nameFiber);
const name = getNameFromFiber(targetFiber.nameFiber);
if (element && name) {
items.push({
element,
name,
});
}
}

for (let i = 0; i < node?.children?.length ?? 0; i++) {
items.push(
...traverseDom(node.children[i] as HTMLElement, activeTraceItems),
);
}

return items;
};
Loading

0 comments on commit 4e37590

Please sign in to comment.