diff --git a/packages/twenty-front-component-renderer/package.json b/packages/twenty-front-component-renderer/package.json index a134dd3b2cf..620955bd960 100644 --- a/packages/twenty-front-component-renderer/package.json +++ b/packages/twenty-front-component-renderer/package.json @@ -43,6 +43,7 @@ "@vitest/browser-playwright": "^4.1.0", "playwright": "^1.60.0", "prettier": "^3.1.1", + "recharts": "^3.9.2", "storybook": "^10.4.6", "styled-components": "^6.1.0", "ts-morph": "^25.0.0", diff --git a/packages/twenty-front-component-renderer/src/__stories__/Geometry.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/Geometry.stories.tsx new file mode 100644 index 00000000000..3ae83d9bf44 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/Geometry.stories.tsx @@ -0,0 +1,94 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fn, waitFor, within } from 'storybook/test'; + +import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender'; +import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer'; + +const GEOMETRY_TIMEOUT = 30000; + +const errorHandler = fn(); + +const meta: Meta = { + title: 'FrontComponent/Geometry', + component: FrontComponentRenderer, + parameters: { + layout: 'centered', + }, + args: { + onError: errorHandler, + applicationAccessToken: 'fake-token', + executionContext: { + frontComponentId: 'storybook-test', + userId: null, + recordId: null, + selectedRecordIds: [], + colorScheme: 'light', + }, + }, + beforeEach: () => { + errorHandler.mockClear(); + }, +}; + +export default meta; +type Story = StoryObj; + +const createGeometryStory = (name: string, play: Story['play']): Story => ({ + args: { + componentUrl: getBuiltStoryComponentPathForRender( + `${name}.front-component`, + ), + }, + play, +}); + +export const MirrorsElementGeometryIntoTheWorker: Story = createGeometryStory( + 'geometry-measure', + async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId( + 'geometry-measure-component', + {}, + { timeout: GEOMETRY_TIMEOUT }, + ); + + await waitFor( + () => { + expect(canvas.getByTestId('geometry-measure-width')).toHaveTextContent( + 'width: 240', + ); + expect(canvas.getByTestId('geometry-measure-height')).toHaveTextContent( + 'height: 80', + ); + expect( + canvas.getByTestId('geometry-measure-offset-width'), + ).toHaveTextContent('offsetWidth: 240'); + expect( + canvas.getByTestId('geometry-measure-inner-width'), + ).not.toHaveTextContent('innerWidth: 0'); + }, + { timeout: GEOMETRY_TIMEOUT }, + ); + + expect(errorHandler).not.toHaveBeenCalled(); + }, +); + +export const RendersRechartsAreaChart: Story = createGeometryStory( + 'recharts-example', + async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId( + 'recharts-component', + {}, + { timeout: GEOMETRY_TIMEOUT }, + ); + + expect(await canvas.findByText('Jan')).toBeVisible(); + expect(await canvas.findByText('Jun')).toBeVisible(); + + expect(errorHandler).not.toHaveBeenCalled(); + }, +); diff --git a/packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx index 6461240635a..f3900ae5f9e 100644 --- a/packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx +++ b/packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx @@ -133,29 +133,19 @@ export const InputPreact: Story = createKnownFailureGalleryStory( 'preact', ); -// KNOWN ISSUE (TDD): base-ui Collapsible calls getComputedStyle, missing from -// the remote-dom Window polyfill. The runtimes diverge (observed -// deterministically): under Preact only the first Collapsible consumer -// crashes and later siblings render. -export const JsonVisualizerReact: Story = createKnownFailureGalleryStory( +export const JsonVisualizerReact: Story = createGalleryStory( 'twenty-ui-json-visualizer-gallery', - ['JsonTree', 'JsonArrayNode', 'JsonObjectNode', 'JsonNestedNode'], ); -export const JsonVisualizerPreact: Story = createKnownFailureGalleryStory( +export const JsonVisualizerPreact: Story = createGalleryStory( 'twenty-ui-json-visualizer-gallery', - ['JsonTree'], 'preact', ); -// KNOWN ISSUE (TDD): same getComputedStyle gap through base-ui Collapsible, -// with the same React/Preact divergence. -export const LayoutReact: Story = createKnownFailureGalleryStory( +export const LayoutReact: Story = createGalleryStory( 'twenty-ui-layout-gallery', - ['AnimatedEaseInOut', 'AnimatedExpandableContainer'], ); -export const LayoutPreact: Story = createKnownFailureGalleryStory( +export const LayoutPreact: Story = createGalleryStory( 'twenty-ui-layout-gallery', - ['AnimatedEaseInOut'], 'preact', ); diff --git a/packages/twenty-front-component-renderer/src/__stories__/showcase/geometry-measure.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/showcase/geometry-measure.front-component.tsx new file mode 100644 index 00000000000..9398eb26cc0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/showcase/geometry-measure.front-component.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; + +const MEASURED_BOX_WIDTH = 240; +const MEASURED_BOX_HEIGHT = 80; + +const GeometryMeasureComponent = () => { + const measuredBoxRef = useRef(null); + const [measuredGeometry, setMeasuredGeometry] = useState({ + width: 0, + height: 0, + offsetWidth: 0, + innerWidth: 0, + }); + + useEffect(() => { + const readMeasuredGeometry = () => { + const measuredBox = measuredBoxRef.current; + + if (measuredBox === null) { + return; + } + + const rect = measuredBox.getBoundingClientRect(); + + setMeasuredGeometry({ + width: Math.round(rect.width), + height: Math.round(rect.height), + offsetWidth: measuredBox.offsetWidth, + innerWidth: window.innerWidth, + }); + }; + + const intervalId = setInterval(readMeasuredGeometry, 50); + + return () => clearInterval(intervalId); + }, []); + + return ( +
+
+

+ width: {measuredGeometry.width} +

+

+ height: {measuredGeometry.height} +

+

+ offsetWidth: {measuredGeometry.offsetWidth} +

+

+ innerWidth: {measuredGeometry.innerWidth} +

+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'fc-geometry-measure-0000-0000-0000-000000000001', + name: 'geometry-measure-component', + description: 'Front component reading its own geometry from the host mirror', + component: GeometryMeasureComponent, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/showcase/recharts-example.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/showcase/recharts-example.front-component.tsx new file mode 100644 index 00000000000..6acc49d4bbb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/showcase/recharts-example.front-component.tsx @@ -0,0 +1,38 @@ +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { defineFrontComponent } from 'twenty-sdk/define'; + +const CHART_DATA = [ + { month: 'Jan', revenue: 4000 }, + { month: 'Feb', revenue: 3000 }, + { month: 'Mar', revenue: 5200 }, + { month: 'Apr', revenue: 4780 }, + { month: 'May', revenue: 5890 }, + { month: 'Jun', revenue: 6390 }, +]; + +const RechartsExampleComponent = () => ( +
+ + + + + + +
+); + +export default defineFrontComponent({ + universalIdentifier: 'fc-recharts-example-00-0000-000000000004', + name: 'recharts-example-component', + description: 'Front component rendering a fixed size recharts area chart', + component: RechartsExampleComponent, +}); diff --git a/packages/twenty-front-component-renderer/src/__tests__/createElementGeometrySnapshotFixture.ts b/packages/twenty-front-component-renderer/src/__tests__/createElementGeometrySnapshotFixture.ts new file mode 100644 index 00000000000..7fccee258b7 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__tests__/createElementGeometrySnapshotFixture.ts @@ -0,0 +1,23 @@ +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; + +export const createElementGeometrySnapshotFixture = ( + overrides: Partial = {}, +): ElementGeometrySnapshot => ({ + x: 1, + y: 2, + width: 3, + height: 4, + offsetWidth: 5, + offsetHeight: 6, + offsetTop: 7, + offsetLeft: 8, + clientWidth: 9, + clientHeight: 10, + clientTop: 11, + clientLeft: 12, + scrollWidth: 13, + scrollHeight: 14, + scrollTop: 15, + scrollLeft: 16, + ...overrides, +}); diff --git a/packages/twenty-front-component-renderer/src/__tests__/createStubGeometryTracker.ts b/packages/twenty-front-component-renderer/src/__tests__/createStubGeometryTracker.ts new file mode 100644 index 00000000000..fba42d8708e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__tests__/createStubGeometryTracker.ts @@ -0,0 +1,13 @@ +import { type GeometryTracker } from '@/host/types/GeometryTracker'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export const createStubGeometryTracker = (): GeometryTracker => ({ + registerNode: jest.fn(), + unregisterNode: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + setRoot: jest.fn(), + setPushGeometryUpdates: jest.fn(), + getViewportGeometry: jest.fn(), + reset: jest.fn(), +}); diff --git a/packages/twenty-front-component-renderer/src/__tests__/createViewportGeometrySnapshotFixture.ts b/packages/twenty-front-component-renderer/src/__tests__/createViewportGeometrySnapshotFixture.ts new file mode 100644 index 00000000000..ad3d1368ea3 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__tests__/createViewportGeometrySnapshotFixture.ts @@ -0,0 +1,18 @@ +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export const createViewportGeometrySnapshotFixture = ( + overrides: Partial = {}, +): ViewportGeometrySnapshot => ({ + innerWidth: 0, + innerHeight: 0, + devicePixelRatio: 1, + scrollX: 0, + scrollY: 0, + rootContainerX: 0, + rootContainerY: 0, + rootContainerWidth: 0, + rootContainerHeight: 0, + rootContainerClientWidth: 0, + rootContainerClientHeight: 0, + ...overrides, +}); diff --git a/packages/twenty-front-component-renderer/src/__tests__/createWorkerGeometryStoreStub.ts b/packages/twenty-front-component-renderer/src/__tests__/createWorkerGeometryStoreStub.ts new file mode 100644 index 00000000000..ce6dc3fc7f4 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__tests__/createWorkerGeometryStoreStub.ts @@ -0,0 +1,12 @@ +import { type WorkerGeometryStore } from '@/polyfills/geometry/types/WorkerGeometryStore'; + +export const createWorkerGeometryStoreStub = ( + overrides: Partial = {}, +): WorkerGeometryStore => ({ + setRootElement: jest.fn(), + connectTransport: jest.fn(), + applyGeometryBatch: jest.fn(), + getViewportSnapshot: jest.fn(() => null), + resolveElementSnapshot: jest.fn(() => null), + ...overrides, +}); diff --git a/packages/twenty-front-component-renderer/src/constants/MaxObservedGeometryElements.ts b/packages/twenty-front-component-renderer/src/constants/MaxObservedGeometryElements.ts new file mode 100644 index 00000000000..b1a54b8cc36 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/constants/MaxObservedGeometryElements.ts @@ -0,0 +1 @@ +export const MAX_OBSERVED_GEOMETRY_ELEMENTS = 500; diff --git a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx index 7844a7b6177..ba128fbe865 100644 --- a/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front-component-renderer/src/host/components/FrontComponentRenderer.tsx @@ -1,7 +1,11 @@ +import { ROOT_CONTAINER_STYLE } from '@/host/constants/RootContainerStyle'; import { FrontComponentExternalNavigationContext } from '@/host/contexts/FrontComponentExternalNavigationContext'; +import { FrontComponentGeometryTrackerContext } from '@/host/contexts/FrontComponentGeometryTrackerContext'; import { type RequestExternalNavigation } from '@/host/types/RequestExternalNavigation'; +import { createGeometryTracker } from '@/host/utils/createGeometryTracker'; import { FrontComponentConfirmationModalResultEffect } from '@/remote/components/FrontComponentConfirmationModalResultEffect'; import { FrontComponentErrorEffect } from '@/remote/components/FrontComponentErrorEffect'; +import { FrontComponentGeometryTrackerEffect } from '@/remote/components/FrontComponentGeometryTrackerEffect'; import { FrontComponentInitializeHostCommunicationApiEffect } from '@/remote/components/FrontComponentInitializeHostCommunicationApiEffect'; import { FrontComponentUpdateContextEffect } from '@/remote/components/FrontComponentUpdateContextEffect'; import { FrontComponentUpdateHostCommunicationApiEffect } from '@/remote/components/FrontComponentUpdateHostCommunicationApiEffect'; @@ -60,75 +64,85 @@ export const FrontComponentRenderer = ({ const [error, setError] = useState(null); const [isExecutionContextInitialized, setIsExecutionContextInitialized] = useState(false); + const [geometryTracker] = useState(() => createGeometryTracker()); const isReady = isDefined(receiver) && isExecutionContextInitialized; return ( - <> - + +
+ - {isDefined(error) && ( - - - - - )} + {isDefined(error) && ( + + + + + )} - {isDefined(thread) && ( - <> - - - - setIsExecutionContextInitialized(true) - } - /> - - - )} + {isDefined(thread) && ( + <> + + + + + setIsExecutionContextInitialized(true) + } + /> + + + )} - {!isDefined(error) && !isReady && loadingFallback} + {!isDefined(error) && !isReady && loadingFallback} - {isReady && ( - - setError(null)} - resetKeys={[componentUrl]} - fallbackRender={() => null} - > - + setError(null)} + resetKeys={[componentUrl]} + fallbackRender={() => null} > - - - - - )} - + + + + + + )} +
+
); }; diff --git a/packages/twenty-front-component-renderer/src/host/constants/GeometryEpsilonPixels.ts b/packages/twenty-front-component-renderer/src/host/constants/GeometryEpsilonPixels.ts new file mode 100644 index 00000000000..e5b6c00baf3 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/GeometryEpsilonPixels.ts @@ -0,0 +1 @@ +export const GEOMETRY_EPSILON_PIXELS = 0.05; diff --git a/packages/twenty-front-component-renderer/src/host/constants/GeometryIdleFrameThreshold.ts b/packages/twenty-front-component-renderer/src/host/constants/GeometryIdleFrameThreshold.ts new file mode 100644 index 00000000000..a9fd6eb692f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/GeometryIdleFrameThreshold.ts @@ -0,0 +1 @@ +export const GEOMETRY_IDLE_FRAME_THRESHOLD = 20; diff --git a/packages/twenty-front-component-renderer/src/host/constants/GeometryUnregisteredObservationExpiryFrames.ts b/packages/twenty-front-component-renderer/src/host/constants/GeometryUnregisteredObservationExpiryFrames.ts new file mode 100644 index 00000000000..af58756c52d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/GeometryUnregisteredObservationExpiryFrames.ts @@ -0,0 +1 @@ +export const GEOMETRY_UNREGISTERED_OBSERVATION_EXPIRY_FRAMES = 20; diff --git a/packages/twenty-front-component-renderer/src/host/constants/RootContainerStyle.ts b/packages/twenty-front-component-renderer/src/host/constants/RootContainerStyle.ts new file mode 100644 index 00000000000..94e2b71cf7a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/RootContainerStyle.ts @@ -0,0 +1,6 @@ +import { type CSSProperties } from 'react'; + +export const ROOT_CONTAINER_STYLE: CSSProperties = { + width: '100%', + height: '100%', +}; diff --git a/packages/twenty-front-component-renderer/src/host/contexts/FrontComponentGeometryTrackerContext.ts b/packages/twenty-front-component-renderer/src/host/contexts/FrontComponentGeometryTrackerContext.ts new file mode 100644 index 00000000000..18772b844cb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/contexts/FrontComponentGeometryTrackerContext.ts @@ -0,0 +1,6 @@ +import { createContext } from 'react'; + +import { type GeometryTracker } from '@/host/types/GeometryTracker'; + +export const FrontComponentGeometryTrackerContext = + createContext(null); diff --git a/packages/twenty-front-component-renderer/src/host/hooks/__tests__/useGeometryNodeRef.test.tsx b/packages/twenty-front-component-renderer/src/host/hooks/__tests__/useGeometryNodeRef.test.tsx new file mode 100644 index 00000000000..c5acd4066ed --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/hooks/__tests__/useGeometryNodeRef.test.tsx @@ -0,0 +1,70 @@ +import '../../utils/__tests__/setupServerRenderingGlobals'; + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { createStubGeometryTracker } from '@/__tests__/createStubGeometryTracker'; +import { FrontComponentGeometryTrackerContext } from '@/host/contexts/FrontComponentGeometryTrackerContext'; +import { type GeometryTracker } from '@/host/types/GeometryTracker'; +import { useGeometryNodeRef } from '../useGeometryNodeRef'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const GeometryNodeRefConsumer = ({ + remoteElementId, +}: { + remoteElementId: string; +}) => createElement('div', { ref: useGeometryNodeRef(remoteElementId) }); + +const renderWithTracker = (tracker: GeometryTracker, remoteElementId: string) => + createElement( + FrontComponentGeometryTrackerContext.Provider, + { value: tracker }, + createElement(GeometryNodeRefConsumer, { remoteElementId }), + ); + +describe('useGeometryNodeRef', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('should re-register the mounted element when the remote id changes', () => { + const tracker = createStubGeometryTracker(); + + act(() => { + root.render(renderWithTracker(tracker, 'first-id')); + }); + + expect(tracker.registerNode).toHaveBeenCalledWith( + 'first-id', + expect.any(HTMLElement), + ); + + act(() => { + root.render(renderWithTracker(tracker, 'second-id')); + }); + + expect(tracker.unregisterNode).toHaveBeenCalledWith( + 'first-id', + expect.any(HTMLElement), + ); + expect(tracker.registerNode).toHaveBeenCalledWith( + 'second-id', + expect.any(HTMLElement), + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useGeometryNodeRef.ts b/packages/twenty-front-component-renderer/src/host/hooks/useGeometryNodeRef.ts new file mode 100644 index 00000000000..7a490bc2729 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/hooks/useGeometryNodeRef.ts @@ -0,0 +1,39 @@ +import { useContext, useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { FrontComponentGeometryTrackerContext } from '@/host/contexts/FrontComponentGeometryTrackerContext'; +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; +import { type GeometryTracker } from '@/host/types/GeometryTracker'; + +const createGeometryNodeRef = ( + geometryTracker: GeometryTracker, + remoteElementId: string, +): ElementRefCallback => { + let registeredElement: Element | null = null; + + return (element: Element | null) => { + if (isDefined(registeredElement)) { + geometryTracker.unregisterNode(remoteElementId, registeredElement); + } + + registeredElement = element; + + if (isDefined(element)) { + geometryTracker.registerNode(remoteElementId, element); + } + }; +}; + +export const useGeometryNodeRef = ( + remoteElementId: string | undefined, +): ElementRefCallback | undefined => { + const geometryTracker = useContext(FrontComponentGeometryTrackerContext); + + return useMemo( + () => + isDefined(geometryTracker) && isDefined(remoteElementId) + ? createGeometryNodeRef(geometryTracker, remoteElementId) + : undefined, + [geometryTracker, remoteElementId], + ); +}; diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts b/packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts index 59bfc58570c..b262aa1f578 100644 --- a/packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts +++ b/packages/twenty-front-component-renderer/src/host/hooks/useHtmlHostElementProps.ts @@ -6,12 +6,14 @@ import { type SetEditableFocused, } from '@/host/contexts/FrontComponentInputFocusContext'; import { useComposedElementRef } from '@/host/hooks/useComposedElementRef'; +import { useGeometryNodeRef } from '@/host/hooks/useGeometryNodeRef'; import { useReactUnsupportedEventListenerRef } from '@/host/hooks/useReactUnsupportedEventListenerRef'; import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; import { buildHostReactPropsFromRemoteProps } from '@/host/utils/buildHostReactPropsFromRemoteProps'; import { createAnchorNavigationClickHandler } from '@/host/utils/createAnchorNavigationClickHandler'; import { createDropTargetGuardProps } from '@/host/utils/createDropTargetGuardProps'; import { extractReactUnsupportedEventHandlers } from '@/host/utils/extractReactUnsupportedEventHandlers'; +import { getRemoteElementIdFromProps } from '@/host/utils/getRemoteElementIdFromProps'; import { preventDefaultThenForwardToRemote } from '@/host/utils/preventDefaultThenForwardToRemote'; import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox'; @@ -31,6 +33,8 @@ export const useHtmlHostElementProps = ( FrontComponentExternalNavigationContext, ); + const remoteElementId = getRemoteElementIdFromProps(props); + const { reactUnsupportedEventHandlers, reactBindableProps } = extractReactUnsupportedEventHandlers( buildHostReactPropsFromRemoteProps(props, htmlTag), @@ -40,8 +44,11 @@ export const useHtmlHostElementProps = ( reactUnsupportedEventHandlers, ); + const geometryNodeRef = useGeometryNodeRef(remoteElementId); + const composedElementRef = useComposedElementRef([ reactUnsupportedEventListenerRef, + geometryNodeRef, ]); const anchorNavigationClickHandler = diff --git a/packages/twenty-front-component-renderer/src/host/types/GeometryTracker.ts b/packages/twenty-front-component-renderer/src/host/types/GeometryTracker.ts new file mode 100644 index 00000000000..7bb928a98cb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/types/GeometryTracker.ts @@ -0,0 +1,16 @@ +import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; +import { type PushGeometryUpdates } from '@/host/types/PushGeometryUpdates'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export type GeometryTracker = { + registerNode: (remoteElementId: string, node: Element) => void; + unregisterNode: (remoteElementId: string, node: Element) => void; + observe: (remoteElementIds: unknown) => void; + unobserve: (remoteElementIds: unknown) => void; + setRoot: ElementRefCallback; + setPushGeometryUpdates: ( + pushGeometryUpdates: PushGeometryUpdates | null, + ) => void; + getViewportGeometry: () => ViewportGeometrySnapshot; + reset: () => void; +}; diff --git a/packages/twenty-front-component-renderer/src/host/types/GeometryWakeSources.ts b/packages/twenty-front-component-renderer/src/host/types/GeometryWakeSources.ts new file mode 100644 index 00000000000..835f24519df --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/types/GeometryWakeSources.ts @@ -0,0 +1,9 @@ +export type GeometryWakeSources = { + attachViewportSources: () => void; + attachElementSources: () => void; + detachElementSources: () => void; + detachAllSources: () => void; + setRoot: (node: Element | null) => void; + startObservingNode: (node: Element) => void; + stopObservingNode: (node: Element) => void; +}; diff --git a/packages/twenty-front-component-renderer/src/host/types/PushGeometryUpdates.ts b/packages/twenty-front-component-renderer/src/host/types/PushGeometryUpdates.ts new file mode 100644 index 00000000000..9be80f0fb45 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/types/PushGeometryUpdates.ts @@ -0,0 +1,3 @@ +import { type GeometryUpdateBatch } from '@/types/GeometryUpdateBatch'; + +export type PushGeometryUpdates = (batch: GeometryUpdateBatch) => void; diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createGeometryTracker.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createGeometryTracker.test.ts new file mode 100644 index 00000000000..2cd2c0036ea --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createGeometryTracker.test.ts @@ -0,0 +1,728 @@ +import { setupGeometryGlobals } from './setupGeometryGlobals'; + +import { GEOMETRY_IDLE_FRAME_THRESHOLD } from '@/host/constants/GeometryIdleFrameThreshold'; +import { createGeometryTracker } from '../createGeometryTracker'; + +const geometryGlobals = setupGeometryGlobals(); + +const armedTrackers: ReturnType[] = []; + +const createArmedTracker = () => { + const tracker = createGeometryTracker(); + const pushGeometryUpdates = jest.fn(); + + tracker.setPushGeometryUpdates(pushGeometryUpdates); + armedTrackers.push(tracker); + + return { tracker, pushGeometryUpdates }; +}; + +const flushFrames = (frameCount: number) => { + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + geometryGlobals.flushAnimationFrame(); + } +}; + +describe('createGeometryTracker', () => { + beforeEach(() => { + geometryGlobals.clearScheduledFrames(); + document.body.innerHTML = ''; + jest.clearAllMocks(); + }); + + afterEach(() => { + for (const tracker of armedTrackers) { + tracker.reset(); + } + armedTrackers.length = 0; + }); + + it('should not schedule a frame when created', () => { + armedTrackers.push(createGeometryTracker()); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should not schedule a frame when observing before being armed', () => { + const tracker = createGeometryTracker(); + armedTrackers.push(tracker); + + tracker.observe(['1']); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should push ids observed before it was armed', () => { + const tracker = createGeometryTracker(); + armedTrackers.push(tracker); + + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 13, + height: 13, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + + const pushGeometryUpdates = jest.fn(); + tracker.setPushGeometryUpdates(pushGeometryUpdates); + + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(1); + expect(pushGeometryUpdates.mock.calls[0][0].elements['1'].width).toBe(13); + }); + + it('should not schedule a frame when arming with nothing observed', () => { + const tracker = createGeometryTracker(); + armedTrackers.push(tracker); + + tracker.setPushGeometryUpdates(jest.fn()); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should not schedule a frame when every observed id is rejected', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + + tracker.observe([42, null, '']); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + expect(pushGeometryUpdates).not.toHaveBeenCalled(); + }); + + it('should not schedule a frame when re-observing an already observed id', () => { + const { tracker } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + tracker.observe(['1']); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should never push while nothing is observed', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', stub.node); + flushFrames(5); + + expect(pushGeometryUpdates).not.toHaveBeenCalled(); + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should push a batch containing only observed nodes', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 1, + y: 2, + width: 3, + height: 4, + }); + const unobserved = geometryGlobals.createStubNode({ + x: 9, + y: 9, + width: 9, + height: 9, + }); + + tracker.registerNode('1', observed.node); + tracker.registerNode('2', unobserved.node); + tracker.observe(['1']); + + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(1); + const batch = pushGeometryUpdates.mock.calls[0][0]; + expect(Object.keys(batch.elements)).toEqual(['1']); + expect(batch.elements['1'].width).toBe(3); + }); + + it('should not push a second batch when nothing changed', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + + flushFrames(3); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(1); + }); + + it('should push when a rect changes by more than the epsilon', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + observed.setGeometry({ x: 0, y: 0, width: 20, height: 10 }); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(2); + expect(pushGeometryUpdates.mock.calls[1][0].elements['1'].width).toBe(20); + }); + + it('should not push when a rect changes by less than the epsilon', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + observed.setGeometry({ x: 0, y: 0, width: 10.01, height: 10 }); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(1); + }); + + it('should include the viewport in every pushed batch', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates.mock.calls[0][0].viewport.innerWidth).toBe( + window.innerWidth, + ); + }); + + it('should stop scheduling frames after the idle threshold', () => { + const { tracker } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should resume scheduling after a window resize following an idle stop', () => { + const { tracker } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + window.dispatchEvent(new Event('resize')); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(1); + }); + + it('should push the resized viewport after a window resize following an idle stop', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + const initialInnerWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { + value: initialInnerWidth + 100, + configurable: true, + }); + + pushGeometryUpdates.mockClear(); + window.dispatchEvent(new Event('resize')); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(1); + expect(pushGeometryUpdates.mock.calls[0][0].viewport.innerWidth).toBe( + initialInnerWidth + 100, + ); + + Object.defineProperty(window, 'innerWidth', { + value: initialInnerWidth, + configurable: true, + }); + }); + + it('should resume scheduling when the resize observer fires', () => { + const { tracker } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + geometryGlobals.triggerResizeObserver(); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(1); + }); + + it('should resume scheduling when the mutation observer fires', () => { + const { tracker } = createArmedTracker(); + const rootContainer = document.createElement('div'); + document.body.append(rootContainer); + tracker.setRoot(rootContainer); + + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + geometryGlobals.triggerMutationObserver(); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(1); + }); + + it('should resume scheduling when a transition starts inside the root after an idle stop', () => { + const { tracker } = createArmedTracker(); + const rootContainer = document.createElement('div'); + document.body.append(rootContainer); + tracker.setRoot(rootContainer); + + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + rootContainer.append(observed.node); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + + observed.node.dispatchEvent(new Event('transitionrun', { bubbles: true })); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(1); + }); + + it('should resume scheduling when a transition ends inside the root after an idle stop', () => { + const { tracker } = createArmedTracker(); + const rootContainer = document.createElement('div'); + document.body.append(rootContainer); + tracker.setRoot(rootContainer); + + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + rootContainer.append(observed.node); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + + observed.node.dispatchEvent(new Event('transitionend', { bubbles: true })); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(1); + }); + + it('should idle despite an animation outside the root', () => { + const { tracker } = createArmedTracker(); + const rootContainer = document.createElement('div'); + const unrelatedContainer = document.createElement('div'); + document.body.append(rootContainer, unrelatedContainer); + tracker.setRoot(rootContainer); + + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + rootContainer.append(observed.node); + + tracker.registerNode('1', observed.node); + tracker.observe(['1']); + + unrelatedContainer.dispatchEvent( + new Event('transitionrun', { bubbles: true }), + ); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should cap the observed set at the configured maximum', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + + const remoteElementIds = Array.from({ length: 10000 }, (_, index) => + String(index), + ); + + for (const remoteElementId of remoteElementIds) { + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + tracker.registerNode(remoteElementId, stub.node); + } + + tracker.observe(remoteElementIds); + geometryGlobals.flushAnimationFrame(); + + expect( + Object.keys(pushGeometryUpdates.mock.calls[0][0].elements).length, + ).toBe(500); + }); + + it('should not grow the observed set when the same ids are observed repeatedly', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + tracker.observe(['1']); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + expect(Object.keys(pushGeometryUpdates.mock.calls[0][0].elements)).toEqual([ + '1', + ]); + }); + + it('should keep an id observed when its node unregisters', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + tracker.unregisterNode('1', stub.node); + stub.node.remove(); + geometryGlobals.flushAnimationFrame(); + + tracker.registerNode('1', stub.node); + document.body.append(stub.node); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(3); + expect(pushGeometryUpdates.mock.calls[2][0].elements['1'].width).toBe(1); + }); + + it('should push again when an id is re-observed after unobserve', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + expect(pushGeometryUpdates).toHaveBeenCalledTimes(1); + + tracker.unobserve(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + pushGeometryUpdates.mockClear(); + + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).toHaveBeenCalledTimes(1); + expect(pushGeometryUpdates.mock.calls[0][0].elements['1'].width).toBe(10); + }); + + it('should detach element wake sources when the last id is unobserved', () => { + const { tracker } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + + document.dispatchEvent(new Event('scroll')); + expect(geometryGlobals.getScheduledFrameCount()).toBe(1); + + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + tracker.unobserve(['1']); + + document.dispatchEvent(new Event('scroll')); + expect(geometryGlobals.getScheduledFrameCount()).toBe(0); + }); + + it('should expire an observed id whose node never registers', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const observed = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', observed.node); + tracker.observe(['1', '404']); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + const removalBatches = pushGeometryUpdates.mock.calls.filter( + (call) => call[0].removedRemoteElementIds.length > 0, + ); + + expect(removalBatches).toHaveLength(1); + expect(removalBatches[0][0].removedRemoteElementIds).toEqual(['404']); + }); + + it('should not expire an observed id that registers before the limit', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const anchor = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + + tracker.registerNode('1', anchor.node); + tracker.observe(['1', '2']); + flushFrames(5); + + const lateStub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 7, + height: 7, + }); + tracker.registerNode('2', lateStub.node); + flushFrames(GEOMETRY_IDLE_FRAME_THRESHOLD + 2); + + for (const call of pushGeometryUpdates.mock.calls) { + expect(call[0].removedRemoteElementIds).toEqual([]); + } + const lastPushWithLateNode = pushGeometryUpdates.mock.calls.find( + (call) => call[0].elements['2'] !== undefined, + ); + expect(lastPushWithLateNode?.[0].elements['2'].width).toBe(7); + }); + + it('should report a removed id exactly once', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + tracker.unregisterNode('1', stub.node); + flushFrames(3); + + const removalBatches = pushGeometryUpdates.mock.calls.filter( + (call) => call[0].removedRemoteElementIds.length > 0, + ); + + expect(removalBatches).toHaveLength(1); + expect(removalBatches[0][0].removedRemoteElementIds).toEqual(['1']); + }); + + it('should not report a removal for an id that was never measured', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + + tracker.observe(['404']); + flushFrames(3); + + for (const call of pushGeometryUpdates.mock.calls) { + expect(call[0].removedRemoteElementIds).toEqual([]); + } + }); + + it('should measure an id registered after it was observed', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 7, + height: 7, + }); + tracker.registerNode('1', stub.node); + geometryGlobals.flushAnimationFrame(); + + const lastBatch = + pushGeometryUpdates.mock.calls[pushGeometryUpdates.mock.calls.length - 1]; + expect(lastBatch[0].elements['1'].width).toBe(7); + }); + + it('should not evict a live node when a stale unregister for the same id arrives', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const staleStub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + const liveStub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 2, + height: 2, + }); + + tracker.registerNode('1', liveStub.node); + tracker.unregisterNode('1', staleStub.node); + tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates.mock.calls[0][0].elements['1'].width).toBe(2); + }); + + it('should ignore non-array and non-string ids', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + + tracker.registerNode('1', stub.node); + tracker.observe('not-an-array'); + tracker.observe([42, null, '']); + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).not.toHaveBeenCalled(); + }); + + it('should not push after reset and should be idempotent when reset twice', () => { + const { tracker, pushGeometryUpdates } = createArmedTracker(); + const stub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 1, + height: 1, + }); + + tracker.registerNode('1', stub.node); + tracker.observe(['1']); + tracker.reset(); + tracker.reset(); + + geometryGlobals.flushAnimationFrame(); + + expect(pushGeometryUpdates).not.toHaveBeenCalled(); + }); + + it('should keep two trackers independent', () => { + const first = createArmedTracker(); + const second = createArmedTracker(); + + const firstStub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 11, + height: 11, + }); + const secondStub = geometryGlobals.createStubNode({ + x: 0, + y: 0, + width: 22, + height: 22, + }); + + first.tracker.registerNode('1', firstStub.node); + second.tracker.registerNode('1', secondStub.node); + + first.tracker.observe(['1']); + geometryGlobals.flushAnimationFrame(); + + expect(first.pushGeometryUpdates).toHaveBeenCalledTimes(1); + expect(first.pushGeometryUpdates.mock.calls[0][0].elements['1'].width).toBe( + 11, + ); + expect(second.pushGeometryUpdates).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts index e99a5bde026..d6ac4a79f50 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts @@ -1,8 +1,10 @@ import './setupServerRenderingGlobals'; -import { act, createElement } from 'react'; +import { REMOTE_ELEMENT_PROP } from '@remote-dom/react/host'; +import { act, createElement, type ComponentType } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; +import { jsx } from 'react/jsx-runtime'; import { FrontComponentExternalNavigationContext } from '@/host/contexts/FrontComponentExternalNavigationContext'; @@ -21,6 +23,11 @@ const renderWrapper = ( createElement(createHtmlHostWrapper(htmlTag), props, children), ); +const createWrapperElement = ( + Wrapper: ComponentType, + props: Record, +) => jsx(Wrapper as never, { ...props } as never); + describe('createHtmlHostWrapper prop hardening', () => { it('should drop an on* attribute whose value is not a function', () => { const markup = renderWrapper('div', { onmouseover: 'alert(1)' }); @@ -99,6 +106,16 @@ describe('createHtmlHostWrapper prop hardening', () => { expect(markup).toContain(dataImageUrl); }); + + it('should not leak the remote element symbol prop into the markup', () => { + const markup = renderToStaticMarkup( + createWrapperElement(createHtmlHostWrapper('div'), { + [REMOTE_ELEMENT_PROP]: { id: '7' }, + }), + ); + + expect(markup).toBe('
'); + }); }); describe('createHtmlHostWrapper client events', () => { @@ -194,6 +211,48 @@ describe('createHtmlHostWrapper client events', () => { expect(node.value).toBe(''); }); + it('should forward focusin through a handler prop that arrives after mount', () => { + const handleFocusIn = jest.fn(); + const Wrapper = createHtmlHostWrapper('div'); + + act(() => { + root.render( + createWrapperElement(Wrapper, { [REMOTE_ELEMENT_PROP]: { id: '7' } }), + ); + }); + + act(() => { + root.render( + createWrapperElement(Wrapper, { + [REMOTE_ELEMENT_PROP]: { id: '7' }, + onFocusin: handleFocusIn, + }), + ); + }); + + const node = container.firstElementChild as HTMLElement; + act(() => { + node.dispatchEvent(new Event('focusin', { bubbles: true })); + }); + + expect(handleFocusIn).toHaveBeenCalledTimes(1); + }); + + it('should not pass the remote dom instance ref to the dom element', () => { + const instanceRef = { current: null as unknown }; + + act(() => { + root.render( + createWrapperElement(createHtmlHostWrapper('div'), { + [REMOTE_ELEMENT_PROP]: { id: '7' }, + ref: instanceRef, + }), + ); + }); + + expect(instanceRef.current).toBeNull(); + }); + it('should stop forwarding focusin after the handler prop is removed', () => { const handleFocusIn = jest.fn(); const Wrapper = createHtmlHostWrapper('div'); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperGeometryRegistration.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperGeometryRegistration.test.ts new file mode 100644 index 00000000000..16b1d9f4fa9 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperGeometryRegistration.test.ts @@ -0,0 +1,145 @@ +import './setupServerRenderingGlobals'; + +import { REMOTE_ELEMENT_PROP } from '@remote-dom/react/host'; +import { act, createElement, type ComponentType } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { jsx } from 'react/jsx-runtime'; + +import { createStubGeometryTracker } from '@/__tests__/createStubGeometryTracker'; +import { FrontComponentGeometryTrackerContext } from '@/host/contexts/FrontComponentGeometryTrackerContext'; +import { type GeometryTracker } from '@/host/types/GeometryTracker'; +import { createHtmlHostWrapper } from '../createHtmlHostWrapper'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const createWrapperElement = ( + Wrapper: ComponentType, + props: Record, +) => jsx(Wrapper as never, { ...props } as never); + +describe('createHtmlHostWrapper geometry registration', () => { + let container: HTMLDivElement; + let root: Root; + let geometryTracker: GeometryTracker; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + geometryTracker = createStubGeometryTracker(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + const renderWithTracker = ( + htmlTag: string, + props: Record, + ) => { + const Wrapper = createHtmlHostWrapper(htmlTag); + + act(() => { + root.render( + createElement( + FrontComponentGeometryTrackerContext.Provider, + { value: geometryTracker }, + createWrapperElement(Wrapper, props), + ), + ); + }); + + return Wrapper; + }; + + it('should register the node on mount using the remote element id', () => { + renderWithTracker('div', { [REMOTE_ELEMENT_PROP]: { id: '7' } }); + + expect(geometryTracker.registerNode).toHaveBeenCalledTimes(1); + expect(geometryTracker.registerNode).toHaveBeenCalledWith( + '7', + container.firstElementChild, + ); + }); + + it('should unregister the node on unmount', () => { + renderWithTracker('div', { [REMOTE_ELEMENT_PROP]: { id: '7' } }); + const node = container.firstElementChild; + + act(() => { + root.render(null); + }); + + expect(geometryTracker.unregisterNode).toHaveBeenCalledWith('7', node); + }); + + it('should register exactly once across repeated re-renders', () => { + const Wrapper = renderWithTracker('div', { + [REMOTE_ELEMENT_PROP]: { id: '7' }, + className: 'a', + }); + + for (const className of ['b', 'c', 'd']) { + act(() => { + root.render( + createElement( + FrontComponentGeometryTrackerContext.Provider, + { value: geometryTracker }, + createWrapperElement(Wrapper, { + [REMOTE_ELEMENT_PROP]: { id: '7' }, + className, + }), + ), + ); + }); + } + + expect(geometryTracker.registerNode).toHaveBeenCalledTimes(1); + expect(geometryTracker.unregisterNode).not.toHaveBeenCalled(); + }); + + it('should register a text input exactly once across repeated re-renders', () => { + const Wrapper = renderWithTracker('input', { + [REMOTE_ELEMENT_PROP]: { id: '7' }, + type: 'text', + value: 'a', + }); + + for (const value of ['ab', 'abc']) { + act(() => { + root.render( + createElement( + FrontComponentGeometryTrackerContext.Provider, + { value: geometryTracker }, + createWrapperElement(Wrapper, { + [REMOTE_ELEMENT_PROP]: { id: '7' }, + type: 'text', + value, + }), + ), + ); + }); + } + + expect(geometryTracker.registerNode).toHaveBeenCalledTimes(1); + expect((container.firstElementChild as HTMLInputElement).value).toBe('abc'); + }); + + it('should not register when there is no tracker in context', () => { + const Wrapper = createHtmlHostWrapper('div'); + + act(() => { + root.render( + createWrapperElement(Wrapper, { [REMOTE_ELEMENT_PROP]: { id: '7' } }), + ); + }); + + expect(container.firstElementChild?.tagName).toBe('DIV'); + expect(geometryTracker.registerNode).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/getRemoteElementIdFromProps.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/getRemoteElementIdFromProps.test.ts new file mode 100644 index 00000000000..b7cb30eb5c4 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/getRemoteElementIdFromProps.test.ts @@ -0,0 +1,27 @@ +import { REMOTE_ELEMENT_PROP } from '@remote-dom/react/host'; + +import { getRemoteElementIdFromProps } from '../getRemoteElementIdFromProps'; + +describe('getRemoteElementIdFromProps', () => { + it('should read the id from the remote element symbol prop', () => { + expect( + getRemoteElementIdFromProps({ [REMOTE_ELEMENT_PROP]: { id: '42' } }), + ).toBe('42'); + }); + + it('should return undefined when the symbol prop is absent', () => { + expect(getRemoteElementIdFromProps({})).toBeUndefined(); + }); + + it('should return undefined when the id is not a string', () => { + expect( + getRemoteElementIdFromProps({ [REMOTE_ELEMENT_PROP]: { id: 42 } }), + ).toBeUndefined(); + }); + + it('should ignore a string keyed element prop supplied by a guest', () => { + expect(getRemoteElementIdFromProps({ element: { id: 'spoofed' } })).toBe( + undefined, + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/isGeometrySnapshotEqualWithinEpsilon.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/isGeometrySnapshotEqualWithinEpsilon.test.ts new file mode 100644 index 00000000000..5b6bc6723bb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/isGeometrySnapshotEqualWithinEpsilon.test.ts @@ -0,0 +1,53 @@ +import { GEOMETRY_EPSILON_PIXELS } from '@/host/constants/GeometryEpsilonPixels'; +import { isGeometrySnapshotEqualWithinEpsilon } from '../isGeometrySnapshotEqualWithinEpsilon'; + +const BASE_SNAPSHOT = { x: 10, y: 20, cursor: 'pointer' }; + +describe('isGeometrySnapshotEqualWithinEpsilon', () => { + it('should return false when the previous snapshot is null', () => { + expect(isGeometrySnapshotEqualWithinEpsilon(null, BASE_SNAPSHOT)).toBe( + false, + ); + }); + + it('should return false when the previous snapshot is undefined', () => { + expect(isGeometrySnapshotEqualWithinEpsilon(undefined, BASE_SNAPSHOT)).toBe( + false, + ); + }); + + it('should return true when the snapshots are equal', () => { + expect( + isGeometrySnapshotEqualWithinEpsilon(BASE_SNAPSHOT, { + ...BASE_SNAPSHOT, + }), + ).toBe(true); + }); + + it('should return true when a numeric value differs by less than the epsilon', () => { + expect( + isGeometrySnapshotEqualWithinEpsilon(BASE_SNAPSHOT, { + ...BASE_SNAPSHOT, + x: BASE_SNAPSHOT.x + GEOMETRY_EPSILON_PIXELS / 2, + }), + ).toBe(true); + }); + + it('should return false when a numeric value differs by more than the epsilon', () => { + expect( + isGeometrySnapshotEqualWithinEpsilon(BASE_SNAPSHOT, { + ...BASE_SNAPSHOT, + y: BASE_SNAPSHOT.y + GEOMETRY_EPSILON_PIXELS * 2, + }), + ).toBe(false); + }); + + it('should return false when a non-numeric value changes', () => { + expect( + isGeometrySnapshotEqualWithinEpsilon(BASE_SNAPSHOT, { + ...BASE_SNAPSHOT, + cursor: 'default', + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/measureNodeGeometry.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/measureNodeGeometry.test.ts new file mode 100644 index 00000000000..5c755c5bfc6 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/measureNodeGeometry.test.ts @@ -0,0 +1,103 @@ +import { measureNodeGeometry } from '../measureNodeGeometry'; + +const ROOT_ORIGIN = { x: 0, y: 0 }; + +const stubBoundingClientRect = (node: Element) => { + node.getBoundingClientRect = () => + ({ + x: 5, + y: 6, + width: 7, + height: 8, + top: 6, + left: 5, + right: 12, + bottom: 14, + toJSON: () => ({}), + }) as DOMRect; +}; + +describe('measureNodeGeometry', () => { + it('should read the bounding rect fields', () => { + const node = document.createElement('div'); + stubBoundingClientRect(node); + + const snapshot = measureNodeGeometry({ + node, + rootContainerOrigin: ROOT_ORIGIN, + }); + + expect(snapshot.x).toBe(5); + expect(snapshot.y).toBe(6); + expect(snapshot.width).toBe(7); + expect(snapshot.height).toBe(8); + }); + + it('should compute offsets relative to the root container origin', () => { + const node = document.createElement('div'); + stubBoundingClientRect(node); + + const snapshot = measureNodeGeometry({ + node, + rootContainerOrigin: { x: 2, y: 4 }, + }); + + expect(snapshot.offsetLeft).toBe(3); + expect(snapshot.offsetTop).toBe(2); + }); + + it('should read the client and scroll fields', () => { + const node = document.createElement('div'); + stubBoundingClientRect(node); + Object.defineProperty(node, 'clientWidth', { value: 21 }); + Object.defineProperty(node, 'clientHeight', { value: 22 }); + Object.defineProperty(node, 'clientTop', { value: 1 }); + Object.defineProperty(node, 'clientLeft', { value: 2 }); + Object.defineProperty(node, 'scrollWidth', { value: 31 }); + Object.defineProperty(node, 'scrollHeight', { value: 32 }); + Object.defineProperty(node, 'scrollTop', { value: 3 }); + Object.defineProperty(node, 'scrollLeft', { value: 4 }); + + const snapshot = measureNodeGeometry({ + node, + rootContainerOrigin: ROOT_ORIGIN, + }); + + expect(snapshot.clientWidth).toBe(21); + expect(snapshot.clientHeight).toBe(22); + expect(snapshot.clientTop).toBe(1); + expect(snapshot.clientLeft).toBe(2); + expect(snapshot.scrollWidth).toBe(31); + expect(snapshot.scrollHeight).toBe(32); + expect(snapshot.scrollTop).toBe(3); + expect(snapshot.scrollLeft).toBe(4); + }); + + it('should read the offset sizes from an html element', () => { + const node = document.createElement('div'); + stubBoundingClientRect(node); + Object.defineProperty(node, 'offsetWidth', { value: 42 }); + Object.defineProperty(node, 'offsetHeight', { value: 43 }); + + const snapshot = measureNodeGeometry({ + node, + rootContainerOrigin: ROOT_ORIGIN, + }); + + expect(snapshot.offsetWidth).toBe(42); + expect(snapshot.offsetHeight).toBe(43); + }); + + it('should report zero offset sizes for an svg element', () => { + const node = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + stubBoundingClientRect(node); + + const snapshot = measureNodeGeometry({ + node, + rootContainerOrigin: ROOT_ORIGIN, + }); + + expect(snapshot.offsetWidth).toBe(0); + expect(snapshot.offsetHeight).toBe(0); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/measureViewportGeometry.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/measureViewportGeometry.test.ts new file mode 100644 index 00000000000..92da860e137 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/measureViewportGeometry.test.ts @@ -0,0 +1,59 @@ +import { measureViewportGeometry } from '../measureViewportGeometry'; + +describe('measureViewportGeometry', () => { + it('should read the window fields', () => { + const viewport = measureViewportGeometry(null); + + expect(viewport.innerWidth).toBe(window.innerWidth); + expect(viewport.innerHeight).toBe(window.innerHeight); + expect(viewport.devicePixelRatio).toBe(window.devicePixelRatio); + expect(viewport.scrollX).toBe(window.scrollX); + expect(viewport.scrollY).toBe(window.scrollY); + }); + + it('should return zero root fields for a null root', () => { + const viewport = measureViewportGeometry(null); + + expect(viewport.rootContainerWidth).toBe(0); + expect(viewport.rootContainerHeight).toBe(0); + expect(viewport.rootContainerClientWidth).toBe(0); + expect(viewport.rootContainerClientHeight).toBe(0); + }); + + it('should not read computed styles', () => { + const getComputedStyle = jest.spyOn(window, 'getComputedStyle'); + const rootContainer = document.createElement('div'); + + measureViewportGeometry(rootContainer); + + expect(getComputedStyle).not.toHaveBeenCalled(); + getComputedStyle.mockRestore(); + }); + + it('should read the root container rect and client sizes', () => { + const rootContainer = document.createElement('div'); + rootContainer.getBoundingClientRect = () => + ({ + x: 1, + y: 2, + width: 300, + height: 400, + top: 2, + left: 1, + right: 301, + bottom: 402, + toJSON: () => ({}), + }) as DOMRect; + Object.defineProperty(rootContainer, 'clientWidth', { value: 290 }); + Object.defineProperty(rootContainer, 'clientHeight', { value: 390 }); + + const viewport = measureViewportGeometry(rootContainer); + + expect(viewport.rootContainerX).toBe(1); + expect(viewport.rootContainerY).toBe(2); + expect(viewport.rootContainerWidth).toBe(300); + expect(viewport.rootContainerHeight).toBe(400); + expect(viewport.rootContainerClientWidth).toBe(290); + expect(viewport.rootContainerClientHeight).toBe(390); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/sanitizeRemoteElementIds.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/sanitizeRemoteElementIds.test.ts new file mode 100644 index 00000000000..5e8918389c9 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/sanitizeRemoteElementIds.test.ts @@ -0,0 +1,24 @@ +import { sanitizeRemoteElementIds } from '../sanitizeRemoteElementIds'; + +describe('sanitizeRemoteElementIds', () => { + it('should return an empty array for non-array input', () => { + expect(sanitizeRemoteElementIds('nope')).toEqual([]); + expect(sanitizeRemoteElementIds(undefined)).toEqual([]); + expect(sanitizeRemoteElementIds({ length: 3 })).toEqual([]); + }); + + it('should drop non-string entries', () => { + expect(sanitizeRemoteElementIds(['1', 2, null, undefined, '3'])).toEqual([ + '1', + '3', + ]); + }); + + it('should drop empty string entries', () => { + expect(sanitizeRemoteElementIds(['', 'ok', ''])).toEqual(['ok']); + }); + + it('should pass valid ids through unchanged', () => { + expect(sanitizeRemoteElementIds(['1', '2', '3'])).toEqual(['1', '2', '3']); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/setupGeometryGlobals.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/setupGeometryGlobals.ts new file mode 100644 index 00000000000..ae09cad981c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/setupGeometryGlobals.ts @@ -0,0 +1,111 @@ +type GeometryFixture = { + x: number; + y: number; + width: number; + height: number; +}; + +export const setupGeometryGlobals = () => { + const scheduledAnimationFrameCallbacksByHandle = new Map< + number, + FrameRequestCallback + >(); + let nextAnimationFrameHandle = 1; + const resizeObserverCallbacks: ResizeObserverCallback[] = []; + const mutationObserverCallbacks: MutationCallback[] = []; + + class StubResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeObserverCallbacks.push(callback); + } + observe() {} + unobserve() {} + disconnect() {} + } + + class StubMutationObserver { + constructor(callback: MutationCallback) { + mutationObserverCallbacks.push(callback); + } + observe() {} + disconnect() {} + takeRecords() { + return []; + } + } + + globalThis.ResizeObserver = + StubResizeObserver as unknown as typeof ResizeObserver; + globalThis.MutationObserver = + StubMutationObserver as unknown as typeof MutationObserver; + + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + const animationFrameHandle = nextAnimationFrameHandle; + nextAnimationFrameHandle += 1; + scheduledAnimationFrameCallbacksByHandle.set( + animationFrameHandle, + callback, + ); + + return animationFrameHandle; + }) as typeof requestAnimationFrame; + + globalThis.cancelAnimationFrame = ((animationFrameHandle: number) => { + scheduledAnimationFrameCallbacksByHandle.delete(animationFrameHandle); + }) as typeof cancelAnimationFrame; + + return { + getScheduledFrameCount: () => scheduledAnimationFrameCallbacksByHandle.size, + flushAnimationFrame: () => { + const [animationFrameHandle, callback] = + scheduledAnimationFrameCallbacksByHandle.entries().next().value ?? []; + + if (animationFrameHandle !== undefined) { + scheduledAnimationFrameCallbacksByHandle.delete(animationFrameHandle); + callback?.(0); + } + }, + clearScheduledFrames: () => { + scheduledAnimationFrameCallbacksByHandle.clear(); + }, + triggerResizeObserver: () => { + for (const callback of resizeObserverCallbacks) { + callback([], {} as ResizeObserver); + } + }, + triggerMutationObserver: () => { + for (const callback of mutationObserverCallbacks) { + callback([], {} as MutationObserver); + } + }, + createStubNode: (geometry: GeometryFixture) => { + const node = document.createElement('div'); + document.body.append(node); + + let currentGeometry = geometry; + + node.getBoundingClientRect = () => { + const top = currentGeometry.y; + const left = currentGeometry.x; + const right = currentGeometry.x + currentGeometry.width; + const bottom = currentGeometry.y + currentGeometry.height; + + return { + ...currentGeometry, + top, + left, + right, + bottom, + toJSON: () => ({ ...currentGeometry, top, left, right, bottom }), + } as DOMRect; + }; + + return { + node, + setGeometry: (nextGeometry: GeometryFixture) => { + currentGeometry = nextGeometry; + }, + }; + }, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts index ef70ea9016f..30df83875b5 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts @@ -1,16 +1,24 @@ import { ThreadMessagePort } from '@quilted/threads'; import { FRONT_COMPONENT_HOST_COMMUNICATION_API_NOOP } from '@/host/constants/FrontComponentHostCommunicationApiNoop'; +import { type GeometryTracker } from '@/host/types/GeometryTracker'; import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports'; import { type FrontComponentThread } from '@/types/FrontComponentThread'; import { type HostFetchFunction } from '@/types/HostFetchFunction'; import { type WorkerExports } from '@/types/WorkerExports'; import { createClonableErrorThreadSerialization } from '@/utils/createClonableErrorThreadSerialization'; -export const createFrontComponentHostThread = ( - hostMessagePort: MessagePort, - hostFetch: HostFetchFunction, -): FrontComponentThread => { +type CreateFrontComponentHostThreadInput = { + hostMessagePort: MessagePort; + hostFetch: HostFetchFunction; + geometryTracker: GeometryTracker; +}; + +export const createFrontComponentHostThread = ({ + hostMessagePort, + hostFetch, + geometryTracker, +}: CreateFrontComponentHostThreadInput): FrontComponentThread => { const thread = new ThreadMessagePort< WorkerExports, FrontComponentHostThreadExports @@ -18,6 +26,12 @@ export const createFrontComponentHostThread = ( exports: { ...FRONT_COMPONENT_HOST_COMMUNICATION_API_NOOP, hostFetch, + observeElementGeometry: async (remoteElementIds) => { + geometryTracker.observe(remoteElementIds); + }, + unobserveElementGeometry: async (remoteElementIds) => { + geometryTracker.unobserve(remoteElementIds); + }, }, serialization: createClonableErrorThreadSerialization(), }); diff --git a/packages/twenty-front-component-renderer/src/host/utils/createGeometryTracker.ts b/packages/twenty-front-component-renderer/src/host/utils/createGeometryTracker.ts new file mode 100644 index 00000000000..02cdc806e8c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/createGeometryTracker.ts @@ -0,0 +1,275 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { MAX_OBSERVED_GEOMETRY_ELEMENTS } from '@/constants/MaxObservedGeometryElements'; +import { GEOMETRY_IDLE_FRAME_THRESHOLD } from '@/host/constants/GeometryIdleFrameThreshold'; +import { GEOMETRY_UNREGISTERED_OBSERVATION_EXPIRY_FRAMES } from '@/host/constants/GeometryUnregisteredObservationExpiryFrames'; +import { type GeometryTracker } from '@/host/types/GeometryTracker'; +import { type PushGeometryUpdates } from '@/host/types/PushGeometryUpdates'; +import { createGeometryWakeSources } from '@/host/utils/createGeometryWakeSources'; +import { isGeometrySnapshotEqualWithinEpsilon } from '@/host/utils/isGeometrySnapshotEqualWithinEpsilon'; +import { measureNodeGeometry } from '@/host/utils/measureNodeGeometry'; +import { measureViewportGeometry } from '@/host/utils/measureViewportGeometry'; +import { sanitizeRemoteElementIds } from '@/host/utils/sanitizeRemoteElementIds'; +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export const createGeometryTracker = (): GeometryTracker => { + const registeredNodes = new Map(); + const observedRemoteElementIds = new Set(); + const lastElementSnapshots = new Map(); + const unregisteredObservedFrameCounts = new Map(); + + let rootContainer: Element | null = null; + let pushGeometryUpdates: PushGeometryUpdates | null = null; + let lastViewportSnapshot: ViewportGeometrySnapshot | null = null; + let animationFrameHandle: number | null = null; + let idleFrameCount = 0; + + const scheduleAnimationFrame = (): void => { + if (isDefined(animationFrameHandle) || !isDefined(pushGeometryUpdates)) { + return; + } + + animationFrameHandle = requestAnimationFrame(() => { + animationFrameHandle = null; + runFrame(); + }); + }; + + const wake = (): void => { + idleFrameCount = 0; + scheduleAnimationFrame(); + }; + + const wakeSources = createGeometryWakeSources(wake); + + const readViewportGeometry = (): ViewportGeometrySnapshot => + measureViewportGeometry(rootContainer); + + const runFrame = (): void => { + if (!isDefined(pushGeometryUpdates)) { + return; + } + + const viewport = readViewportGeometry(); + const rootContainerOrigin = { + x: viewport.rootContainerX, + y: viewport.rootContainerY, + }; + + const changedElements: Record = {}; + const removedRemoteElementIds: string[] = []; + + const expiredRemoteElementIds: string[] = []; + + for (const remoteElementId of observedRemoteElementIds) { + const node = registeredNodes.get(remoteElementId); + + if (!isDefined(node) || !node.isConnected) { + const unregisteredFrameCount = + (unregisteredObservedFrameCounts.get(remoteElementId) ?? 0) + 1; + unregisteredObservedFrameCounts.set( + remoteElementId, + unregisteredFrameCount, + ); + + const hadSnapshot = lastElementSnapshots.delete(remoteElementId); + const hasExpired = + unregisteredFrameCount >= + GEOMETRY_UNREGISTERED_OBSERVATION_EXPIRY_FRAMES; + + if (hasExpired) { + expiredRemoteElementIds.push(remoteElementId); + } + + if (hadSnapshot || hasExpired) { + removedRemoteElementIds.push(remoteElementId); + } + continue; + } + + unregisteredObservedFrameCounts.delete(remoteElementId); + + const snapshot = measureNodeGeometry({ node, rootContainerOrigin }); + + if ( + !isGeometrySnapshotEqualWithinEpsilon( + lastElementSnapshots.get(remoteElementId), + snapshot, + ) + ) { + lastElementSnapshots.set(remoteElementId, snapshot); + changedElements[remoteElementId] = snapshot; + } + } + + for (const remoteElementId of expiredRemoteElementIds) { + observedRemoteElementIds.delete(remoteElementId); + unregisteredObservedFrameCounts.delete(remoteElementId); + } + + if ( + expiredRemoteElementIds.length > 0 && + observedRemoteElementIds.size === 0 + ) { + wakeSources.detachElementSources(); + } + + const hasViewportChanged = !isGeometrySnapshotEqualWithinEpsilon( + lastViewportSnapshot, + viewport, + ); + + lastViewportSnapshot = viewport; + + const hasChangedElements = Object.keys(changedElements).length > 0; + + if ( + hasViewportChanged || + hasChangedElements || + removedRemoteElementIds.length > 0 + ) { + idleFrameCount = 0; + pushGeometryUpdates({ + viewport, + elements: changedElements, + removedRemoteElementIds, + }); + } else { + idleFrameCount += 1; + } + + if (idleFrameCount < GEOMETRY_IDLE_FRAME_THRESHOLD) { + scheduleAnimationFrame(); + } + }; + + const registerNode = (remoteElementId: string, node: Element): void => { + const previousNode = registeredNodes.get(remoteElementId); + + if (isDefined(previousNode) && previousNode !== node) { + wakeSources.stopObservingNode(previousNode); + } + + registeredNodes.set(remoteElementId, node); + unregisteredObservedFrameCounts.delete(remoteElementId); + + if (observedRemoteElementIds.has(remoteElementId)) { + wakeSources.startObservingNode(node); + wake(); + } + }; + + const unregisterNode = (remoteElementId: string, node: Element): void => { + if (registeredNodes.get(remoteElementId) !== node) { + return; + } + + registeredNodes.delete(remoteElementId); + wakeSources.stopObservingNode(node); + + if (observedRemoteElementIds.has(remoteElementId)) { + wake(); + } + }; + + const observe = (remoteElementIds: unknown): void => { + let hasNewlyObservedRemoteElementIds = false; + + for (const remoteElementId of sanitizeRemoteElementIds(remoteElementIds)) { + if (observedRemoteElementIds.size >= MAX_OBSERVED_GEOMETRY_ELEMENTS) { + break; + } + + if (observedRemoteElementIds.has(remoteElementId)) { + continue; + } + + observedRemoteElementIds.add(remoteElementId); + hasNewlyObservedRemoteElementIds = true; + } + + if (!hasNewlyObservedRemoteElementIds) { + return; + } + + wakeSources.attachElementSources(); + + for (const remoteElementId of observedRemoteElementIds) { + const node = registeredNodes.get(remoteElementId); + + if (isDefined(node)) { + wakeSources.startObservingNode(node); + } + } + + wake(); + }; + + const unobserve = (remoteElementIds: unknown): void => { + for (const remoteElementId of sanitizeRemoteElementIds(remoteElementIds)) { + observedRemoteElementIds.delete(remoteElementId); + lastElementSnapshots.delete(remoteElementId); + unregisteredObservedFrameCounts.delete(remoteElementId); + + const node = registeredNodes.get(remoteElementId); + + if (isDefined(node)) { + wakeSources.stopObservingNode(node); + } + } + + if (observedRemoteElementIds.size === 0) { + wakeSources.detachElementSources(); + } + }; + + const setRoot = (node: Element | null): void => { + rootContainer = node; + wakeSources.setRoot(node); + }; + + const setPushGeometryUpdates = ( + nextPushGeometryUpdates: PushGeometryUpdates | null, + ): void => { + pushGeometryUpdates = nextPushGeometryUpdates; + + if (!isDefined(nextPushGeometryUpdates)) { + return; + } + + wakeSources.attachViewportSources(); + + if (observedRemoteElementIds.size > 0) { + wake(); + } + }; + + const reset = (): void => { + pushGeometryUpdates = null; + + if (isDefined(animationFrameHandle)) { + cancelAnimationFrame(animationFrameHandle); + animationFrameHandle = null; + } + + wakeSources.detachAllSources(); + + observedRemoteElementIds.clear(); + lastElementSnapshots.clear(); + unregisteredObservedFrameCounts.clear(); + lastViewportSnapshot = null; + idleFrameCount = 0; + }; + + return { + registerNode, + unregisterNode, + observe, + unobserve, + setRoot, + setPushGeometryUpdates, + getViewportGeometry: readViewportGeometry, + reset, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/createGeometryWakeSources.ts b/packages/twenty-front-component-renderer/src/host/utils/createGeometryWakeSources.ts new file mode 100644 index 00000000000..1b67e2c17d7 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/createGeometryWakeSources.ts @@ -0,0 +1,223 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type GeometryWakeSources } from '@/host/types/GeometryWakeSources'; + +const ANIMATION_EVENT_TYPES = [ + 'transitionrun', + 'transitionend', + 'transitioncancel', + 'animationstart', + 'animationend', + 'animationcancel', +]; + +const MUTATION_OBSERVER_OPTIONS: MutationObserverInit = { + subtree: true, + childList: true, + attributes: true, + characterData: true, +}; + +export const createGeometryWakeSources = ( + onWake: () => void, +): GeometryWakeSources => { + const resizeObservedNodes = new Set(); + + let rootContainer: Element | null = null; + let resizeObserver: ResizeObserver | null = null; + let mutationObserver: MutationObserver | null = null; + let documentStyleObserver: MutationObserver | null = null; + let areViewportSourcesAttached = false; + let areElementSourcesAttached = false; + + const isEventTargetRelevantToRoot = (target: EventTarget | null): boolean => { + if (!isDefined(rootContainer)) { + return false; + } + + if (!(target instanceof Node)) { + return false; + } + + return rootContainer.contains(target) || target.contains(rootContainer); + }; + + const handleAnimationEvent = (event: Event): void => { + if (!isEventTargetRelevantToRoot(event.target)) { + return; + } + + onWake(); + }; + + const handleScroll = (event: Event): void => { + if ( + event.target !== document && + !isEventTargetRelevantToRoot(event.target) + ) { + return; + } + + onWake(); + }; + + const resolveResizeObserver = (): ResizeObserver | null => { + if (isDefined(resizeObserver) || typeof ResizeObserver !== 'function') { + return resizeObserver; + } + + resizeObserver = new ResizeObserver(onWake); + + return resizeObserver; + }; + + const observeRootMutations = (node: Element): void => { + if (typeof MutationObserver !== 'function') { + return; + } + + mutationObserver = new MutationObserver(onWake); + mutationObserver.observe(node, MUTATION_OBSERVER_OPTIONS); + }; + + const startObservingNode = (node: Element): void => { + if (!areElementSourcesAttached || resizeObservedNodes.has(node)) { + return; + } + + const observer = resolveResizeObserver(); + + if (!isDefined(observer)) { + return; + } + + observer.observe(node); + resizeObservedNodes.add(node); + }; + + const stopObservingNode = (node: Element): void => { + if (!resizeObservedNodes.delete(node)) { + return; + } + + resizeObserver?.unobserve(node); + }; + + const observeDocumentStyleMutations = (): void => { + if (typeof MutationObserver !== 'function') { + return; + } + + documentStyleObserver = new MutationObserver(onWake); + documentStyleObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + documentStyleObserver.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + }; + + const attachViewportSources = (): void => { + if (areViewportSourcesAttached) { + return; + } + + areViewportSourcesAttached = true; + + window.addEventListener('resize', onWake); + observeDocumentStyleMutations(); + + if (isDefined(rootContainer)) { + resolveResizeObserver()?.observe(rootContainer); + } + }; + + const attachElementSources = (): void => { + if (areElementSourcesAttached) { + return; + } + + areElementSourcesAttached = true; + + document.addEventListener('scroll', handleScroll, true); + + for (const eventType of ANIMATION_EVENT_TYPES) { + document.addEventListener(eventType, handleAnimationEvent, true); + } + + if (isDefined(rootContainer)) { + observeRootMutations(rootContainer); + } + }; + + const detachElementSources = (): void => { + if (!areElementSourcesAttached) { + return; + } + + areElementSourcesAttached = false; + + document.removeEventListener('scroll', handleScroll, true); + + for (const eventType of ANIMATION_EVENT_TYPES) { + document.removeEventListener(eventType, handleAnimationEvent, true); + } + + mutationObserver?.disconnect(); + mutationObserver = null; + + for (const node of resizeObservedNodes) { + resizeObserver?.unobserve(node); + } + resizeObservedNodes.clear(); + }; + + const detachAllSources = (): void => { + detachElementSources(); + + if (areViewportSourcesAttached) { + areViewportSourcesAttached = false; + window.removeEventListener('resize', onWake); + documentStyleObserver?.disconnect(); + documentStyleObserver = null; + } + + resizeObserver?.disconnect(); + resizeObserver = null; + }; + + const setRoot = (node: Element | null): void => { + if (isDefined(rootContainer)) { + resizeObserver?.unobserve(rootContainer); + } + + mutationObserver?.disconnect(); + mutationObserver = null; + + rootContainer = node; + + if (!isDefined(node)) { + return; + } + + if (areViewportSourcesAttached) { + resolveResizeObserver()?.observe(node); + } + + if (areElementSourcesAttached) { + observeRootMutations(node); + } + }; + + return { + attachViewportSources, + attachElementSources, + detachElementSources, + detachAllSources, + setRoot, + startObservingNode, + stopObservingNode, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/getRemoteElementIdFromProps.ts b/packages/twenty-front-component-renderer/src/host/utils/getRemoteElementIdFromProps.ts new file mode 100644 index 00000000000..20d34b1d192 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/getRemoteElementIdFromProps.ts @@ -0,0 +1,18 @@ +import { REMOTE_ELEMENT_PROP } from '@remote-dom/react/host'; +import { isString } from '@sniptt/guards'; + +type RemoteElementPropCarrier = { + [REMOTE_ELEMENT_PROP]?: { id?: unknown }; +}; + +export const getRemoteElementIdFromProps = ( + props: Record, +): string | undefined => { + const remoteElement = (props as RemoteElementPropCarrier)[ + REMOTE_ELEMENT_PROP + ]; + + const remoteElementId = remoteElement?.id; + + return isString(remoteElementId) ? remoteElementId : undefined; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/isGeometrySnapshotEqualWithinEpsilon.ts b/packages/twenty-front-component-renderer/src/host/utils/isGeometrySnapshotEqualWithinEpsilon.ts new file mode 100644 index 00000000000..044bc72b370 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/isGeometrySnapshotEqualWithinEpsilon.ts @@ -0,0 +1,25 @@ +import { isNumber } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { GEOMETRY_EPSILON_PIXELS } from '@/host/constants/GeometryEpsilonPixels'; + +export const isGeometrySnapshotEqualWithinEpsilon = < + TSnapshot extends Record, +>( + previousSnapshot: TSnapshot | null | undefined, + nextSnapshot: TSnapshot, +): boolean => { + if (!isDefined(previousSnapshot)) { + return false; + } + + return Object.entries(nextSnapshot).every(([snapshotKey, nextValue]) => { + const previousValue = previousSnapshot[snapshotKey]; + + if (isNumber(nextValue) && isNumber(previousValue)) { + return Math.abs(previousValue - nextValue) < GEOMETRY_EPSILON_PIXELS; + } + + return previousValue === nextValue; + }); +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/measureNodeGeometry.ts b/packages/twenty-front-component-renderer/src/host/utils/measureNodeGeometry.ts new file mode 100644 index 00000000000..26bfc954ffb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/measureNodeGeometry.ts @@ -0,0 +1,37 @@ +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; + +type MeasureNodeGeometryInput = { + node: Element; + rootContainerOrigin: { + x: number; + y: number; + }; +}; + +export const measureNodeGeometry = ({ + node, + rootContainerOrigin, +}: MeasureNodeGeometryInput): ElementGeometrySnapshot => { + const { x, y, width, height } = node.getBoundingClientRect(); + + const htmlNode = node instanceof HTMLElement ? node : null; + + return { + x, + y, + width, + height, + offsetWidth: htmlNode?.offsetWidth ?? 0, + offsetHeight: htmlNode?.offsetHeight ?? 0, + offsetTop: y - rootContainerOrigin.y, + offsetLeft: x - rootContainerOrigin.x, + clientWidth: node.clientWidth, + clientHeight: node.clientHeight, + clientTop: node.clientTop, + clientLeft: node.clientLeft, + scrollWidth: node.scrollWidth, + scrollHeight: node.scrollHeight, + scrollTop: node.scrollTop, + scrollLeft: node.scrollLeft, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/measureViewportGeometry.ts b/packages/twenty-front-component-renderer/src/host/utils/measureViewportGeometry.ts new file mode 100644 index 00000000000..742e724782d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/measureViewportGeometry.ts @@ -0,0 +1,25 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export const measureViewportGeometry = ( + rootContainer: Element | null, +): ViewportGeometrySnapshot => { + const rootContainerRect = isDefined(rootContainer) + ? rootContainer.getBoundingClientRect() + : null; + + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + scrollX: window.scrollX, + scrollY: window.scrollY, + rootContainerX: rootContainerRect?.x ?? 0, + rootContainerY: rootContainerRect?.y ?? 0, + rootContainerWidth: rootContainerRect?.width ?? 0, + rootContainerHeight: rootContainerRect?.height ?? 0, + rootContainerClientWidth: rootContainer?.clientWidth ?? 0, + rootContainerClientHeight: rootContainer?.clientHeight ?? 0, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/sanitizeRemoteElementIds.ts b/packages/twenty-front-component-renderer/src/host/utils/sanitizeRemoteElementIds.ts new file mode 100644 index 00000000000..da933a74232 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/sanitizeRemoteElementIds.ts @@ -0,0 +1,11 @@ +import { isArray, isNonEmptyString } from '@sniptt/guards'; + +export const sanitizeRemoteElementIds = ( + remoteElementIds: unknown, +): string[] => { + if (!isArray(remoteElementIds)) { + return []; + } + + return remoteElementIds.filter(isNonEmptyString); +}; diff --git a/packages/twenty-front-component-renderer/src/index.ts b/packages/twenty-front-component-renderer/src/index.ts index c973a9bcafc..ca3463fda9d 100644 --- a/packages/twenty-front-component-renderer/src/index.ts +++ b/packages/twenty-front-component-renderer/src/index.ts @@ -136,6 +136,9 @@ export { installStyleBridge } from './polyfills/installStyleBridge'; export { exposeGlobals } from './remote/utils/exposeGlobals'; export type { FrontComponentExecutionContext } from 'twenty-sdk/front-component'; export type { FrontComponentHostCommunicationApi } from './types/FrontComponentHostCommunicationApi'; +export type { ElementGeometrySnapshot } from './types/ElementGeometrySnapshot'; +export type { ViewportGeometrySnapshot } from './types/ViewportGeometrySnapshot'; +export type { GeometryUpdateBatch } from './types/GeometryUpdateBatch'; export type { HostToWorkerRenderContext } from './types/HostToWorkerRenderContext'; export type { SdkClientUrls } from './types/SdkClientUrls'; export type { PropertySchema } from './constants/PropertySchema'; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementLike.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementLike.ts new file mode 100644 index 00000000000..4a78761b0f4 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementLike.ts @@ -0,0 +1,4 @@ +export type ElementLike = { + childNodes?: ArrayLike; + getAttribute?: (attributeName: string) => string | null; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementWithStyle.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementWithStyle.ts new file mode 100644 index 00000000000..f61f0c1d9fc --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementWithStyle.ts @@ -0,0 +1,3 @@ +export type ElementWithStyle = { + style?: unknown; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installDocumentGetElementById.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installDocumentGetElementById.test.ts new file mode 100644 index 00000000000..1c7890687af --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installDocumentGetElementById.test.ts @@ -0,0 +1,80 @@ +import { installDocumentGetElementById } from '../installDocumentGetElementById'; + +type FakeNode = { + childNodes: FakeNode[]; + getAttribute?: (attributeName: string) => string | null; +}; + +const createElementNode = (elementId?: string): FakeNode => ({ + childNodes: [], + getAttribute: (attributeName: string) => + attributeName === 'id' && elementId !== undefined ? elementId : null, +}); + +type InstalledDocument = FakeNode & { + getElementById: (elementId: string) => FakeNode | null; +}; + +const createDocumentTarget = (): InstalledDocument => { + const documentTarget: FakeNode = { childNodes: [] }; + installDocumentGetElementById(documentTarget); + + return documentTarget as InstalledDocument; +}; + +describe('installDocumentGetElementById', () => { + it('should find a nested element by id', () => { + const documentTarget = createDocumentTarget(); + const parent = createElementNode(); + const target = createElementNode('probe'); + + documentTarget.childNodes.push(parent); + parent.childNodes.push(target); + + expect(documentTarget.getElementById('probe')).toBe(target); + }); + + it('should return null when no element matches', () => { + const documentTarget = createDocumentTarget(); + documentTarget.childNodes.push(createElementNode('other')); + + expect(documentTarget.getElementById('missing')).toBeNull(); + }); + + it('should return null for an empty id even when an element has an empty id attribute', () => { + const documentTarget = createDocumentTarget(); + documentTarget.childNodes.push(createElementNode('')); + + expect(documentTarget.getElementById('')).toBeNull(); + }); + + it('should find an id containing css selector characters', () => { + const documentTarget = createDocumentTarget(); + const target = createElementNode('foo.bar:baz'); + documentTarget.childNodes.push(target); + + expect(documentTarget.getElementById('foo.bar:baz')).toBe(target); + }); + + it('should skip nodes without attributes', () => { + const documentTarget = createDocumentTarget(); + const textLikeNode = { childNodes: [] } as FakeNode; + const target = createElementNode('probe'); + + documentTarget.childNodes.push(textLikeNode, target); + + expect(documentTarget.getElementById('probe')).toBe(target); + }); + + it('should not override an existing getElementById', () => { + const existingGetElementById = jest.fn(); + const documentTarget = { + childNodes: [], + getElementById: existingGetElementById, + }; + + installDocumentGetElementById(documentTarget); + + expect(documentTarget.getElementById).toBe(existingGetElementById); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetComputedStyle.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetComputedStyle.test.ts new file mode 100644 index 00000000000..195c3cd8422 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetComputedStyle.test.ts @@ -0,0 +1,62 @@ +import { installGetComputedStyle } from '../installGetComputedStyle'; + +type StyleDeclarationLike = { + getPropertyValue: (propertyName: string) => string; + fontSize?: unknown; +}; + +type GetComputedStyleLike = ( + element: unknown, + pseudoElement?: unknown, +) => StyleDeclarationLike; + +describe('installGetComputedStyle', () => { + it('should define getComputedStyle on both the global scope and a distinct window', () => { + const polyfillWindow: Record = {}; + const globalScope: Record = { window: polyfillWindow }; + + installGetComputedStyle(globalScope); + + expect(typeof globalScope.getComputedStyle).toBe('function'); + expect(typeof polyfillWindow.getComputedStyle).toBe('function'); + }); + + it('should return the declared style of the element', () => { + const globalScope: Record = {}; + installGetComputedStyle(globalScope); + + const style = { getPropertyValue: () => '14px', fontSize: '14px' }; + + expect( + (globalScope.getComputedStyle as GetComputedStyleLike)({ style }) + .fontSize, + ).toBe('14px'); + }); + + it('should return an empty declaration for an element without a style', () => { + const globalScope: Record = {}; + installGetComputedStyle(globalScope); + + const declaration = (globalScope.getComputedStyle as GetComputedStyleLike)( + {}, + ); + + expect(declaration.getPropertyValue('font-size')).toBe(''); + expect(declaration.fontSize).toBe(''); + }); + + it('should return an empty declaration for a pseudo-element argument', () => { + const globalScope: Record = {}; + installGetComputedStyle(globalScope); + + const style = { getPropertyValue: () => '14px', fontSize: '14px' }; + + const declaration = (globalScope.getComputedStyle as GetComputedStyleLike)( + { style }, + '::before', + ); + + expect(declaration.getPropertyValue('font-size')).toBe(''); + expect(declaration.fontSize).toBe(''); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetElementsByClassName.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetElementsByClassName.test.ts new file mode 100644 index 00000000000..abc2e759879 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetElementsByClassName.test.ts @@ -0,0 +1,138 @@ +import { installGetElementsByClassName } from '../installGetElementsByClassName'; + +class FakeElement { + childNodes: FakeElement[] = []; + className?: string; + private attributes = new Map(); + + constructor(classAttribute?: string) { + if (classAttribute !== undefined) { + this.attributes.set('class', classAttribute); + } + } + + getAttribute(attributeName: string): string | null { + return this.attributes.get(attributeName) ?? null; + } + + append(...children: FakeElement[]): void { + this.childNodes.push(...children); + } +} + +installGetElementsByClassName(FakeElement.prototype); + +type ClassNameQueryResult = FakeElement[] & { + item: (index: number) => FakeElement | null; +}; + +type ElementWithGetElementsByClassName = FakeElement & { + getElementsByClassName: (classNames: string) => ClassNameQueryResult; +}; + +const asInstalled = (element: FakeElement) => + element as ElementWithGetElementsByClassName; + +describe('installGetElementsByClassName', () => { + it('should find a nested descendant by class name', () => { + const rootElement = new FakeElement(); + const intermediate = new FakeElement('layer'); + const target = new FakeElement('recharts-cartesian-axis-tick-value'); + + rootElement.append(intermediate); + intermediate.append(target); + + const matches = asInstalled(rootElement).getElementsByClassName( + 'recharts-cartesian-axis-tick-value', + ); + + expect(matches).toHaveLength(1); + expect(matches[0]).toBe(target); + }); + + it('should match an element whose classes are reflected via the className property', () => { + const rootElement = new FakeElement(); + const target = new FakeElement(); + target.className = 'recharts-layer recharts-line'; + + rootElement.append(target); + + const matches = asInstalled(rootElement).getElementsByClassName( + 'recharts-layer recharts-line', + ); + + expect(matches).toHaveLength(1); + expect(matches[0]).toBe(target); + }); + + it('should not match the element itself', () => { + const rootElement = new FakeElement('self'); + + expect( + asInstalled(rootElement).getElementsByClassName('self'), + ).toHaveLength(0); + }); + + it('should require every requested token', () => { + const rootElement = new FakeElement(); + const both = new FakeElement('first second'); + const onlyFirst = new FakeElement('first'); + + rootElement.append(both, onlyFirst); + + const matches = + asInstalled(rootElement).getElementsByClassName('first second'); + + expect(matches).toHaveLength(1); + expect(matches[0]).toBe(both); + }); + + it('should return matches in depth first pre-order', () => { + const rootElement = new FakeElement(); + const firstBranch = new FakeElement('match'); + const nestedInFirstBranch = new FakeElement('match'); + const secondBranch = new FakeElement('match'); + + rootElement.append(firstBranch, secondBranch); + firstBranch.append(nestedInFirstBranch); + + const matches = asInstalled(rootElement).getElementsByClassName('match'); + + expect(matches).toHaveLength(3); + expect(matches[0]).toBe(firstBranch); + expect(matches[1]).toBe(nestedInFirstBranch); + expect(matches[2]).toBe(secondBranch); + }); + + it('should return an empty result for a blank query', () => { + const rootElement = new FakeElement(); + rootElement.append(new FakeElement('anything')); + + expect(asInstalled(rootElement).getElementsByClassName(' ')).toHaveLength( + 0, + ); + }); + + it('should skip nodes without attributes', () => { + const rootElement = new FakeElement(); + const textLikeNode = {} as FakeElement; + const target = new FakeElement('match'); + + rootElement.childNodes.push(textLikeNode, target); + + const matches = asInstalled(rootElement).getElementsByClassName('match'); + + expect(matches).toHaveLength(1); + }); + + it('should expose item returning null past the end', () => { + const rootElement = new FakeElement(); + const target = new FakeElement('match'); + rootElement.append(target); + + const matches = asInstalled(rootElement).getElementsByClassName('match'); + + expect(matches.item(0)).toBe(target); + expect(matches.item(1)).toBeNull(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installLocalStyleOnBaseElements.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installLocalStyleOnBaseElements.test.ts new file mode 100644 index 00000000000..26fd9b96ff2 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installLocalStyleOnBaseElements.test.ts @@ -0,0 +1,78 @@ +import { installLocalStyleOnBaseElements } from '../installLocalStyleOnBaseElements'; + +class FakeElement {} +class FakeRemoteElement extends FakeElement {} + +describe('installLocalStyleOnBaseElements', () => { + beforeAll(() => { + installLocalStyleOnBaseElements(FakeElement.prototype); + }); + + it('should make Object.assign onto element style succeed', () => { + const element = new FakeElement() as unknown as HTMLElement; + + expect(() => + Object.assign(element.style, { position: 'absolute', fontSize: '14px' }), + ).not.toThrow(); + expect(element.style.fontSize).toBe('14px'); + }); + + it('should round trip values through getPropertyValue', () => { + const element = new FakeElement() as unknown as HTMLElement; + + element.style.fontFamily = 'Inter'; + + expect(element.style.getPropertyValue('font-family')).toBe('Inter'); + }); + + it('should return a distinct declaration per element', () => { + const first = new FakeElement() as unknown as HTMLElement; + const second = new FakeElement() as unknown as HTMLElement; + + first.style.color = 'red'; + + expect(second.style.color).toBe(''); + }); + + it('should keep semicolons inside quoted values and urls in cssText', () => { + const element = new FakeElement() as unknown as HTMLElement; + + element.style.cssText = + 'content: "a;b"; background: url(data:image/png;base64,abc)'; + + expect(element.style.getPropertyValue('content')).toBe('"a;b"'); + expect(element.style.getPropertyValue('background')).toBe( + 'url(data:image/png;base64,abc)', + ); + }); + + it('should populate declarations from a cssText assignment', () => { + const element = new FakeElement() as unknown as HTMLElement; + + element.style.color = 'red'; + element.style.cssText = 'font-size: 14px; font-weight: 700'; + + expect(element.style.getPropertyValue('font-size')).toBe('14px'); + expect(element.style.getPropertyValue('font-weight')).toBe('700'); + expect(element.style.getPropertyValue('color')).toBe(''); + expect(element.style.getPropertyValue('css-text')).toBe(''); + }); + + it('should return the same declaration across reads of one element', () => { + const element = new FakeElement() as unknown as HTMLElement; + + expect(element.style).toBe(element.style); + }); + + it('should be shadowed by a style property defined on a subclass prototype', () => { + const subclassStyle = { marker: 'subclass' }; + Object.defineProperty(FakeRemoteElement.prototype, 'style', { + get: () => subclassStyle, + configurable: true, + }); + + const element = new FakeRemoteElement() as unknown as HTMLElement; + + expect(element.style).toBe(subclassStyle as unknown as CSSStyleDeclaration); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installDocumentGetElementById.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installDocumentGetElementById.ts new file mode 100644 index 00000000000..e8b46a1d12d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installDocumentGetElementById.ts @@ -0,0 +1,33 @@ +import { isFunction, isNonEmptyString } from '@sniptt/guards'; + +import { type ElementLike } from '@/polyfills/dom/types/ElementLike'; +import { iterateElementSubtree } from '@/polyfills/dom/utils/iterateElementSubtree'; + +type DocumentWithGetElementById = ElementLike & { + getElementById?: unknown; +}; + +export const installDocumentGetElementById = ( + documentTarget: DocumentWithGetElementById, +): void => { + if (isFunction(documentTarget.getElementById)) { + return; + } + + documentTarget.getElementById = (elementId: string) => { + if (!isNonEmptyString(elementId)) { + return null; + } + + for (const currentNode of iterateElementSubtree(documentTarget)) { + if ( + isFunction(currentNode.getAttribute) && + currentNode.getAttribute('id') === elementId + ) { + return currentNode; + } + } + + return null; + }; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetComputedStyle.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetComputedStyle.ts new file mode 100644 index 00000000000..4afb956aa22 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetComputedStyle.ts @@ -0,0 +1,30 @@ +import { isNonEmptyString, isObject } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { type ElementWithStyle } from '@/polyfills/dom/types/ElementWithStyle'; +import { createStyleProxy } from '@/polyfills/dom/utils/createStyleProxy'; +import { resolveGlobalScopeInstallTargets } from '@/polyfills/utils/resolveGlobalScopeInstallTargets'; + +export const installGetComputedStyle = ( + globalScope: Record, +): void => { + const createEmptyStyleDeclaration = () => createStyleProxy(() => {}); + + const getComputedStyle = (element: unknown, pseudoElement?: unknown) => { + if (isNonEmptyString(pseudoElement)) { + return createEmptyStyleDeclaration(); + } + + const declaredStyle = isObject(element) + ? (element as ElementWithStyle).style + : undefined; + + return isDefined(declaredStyle) + ? declaredStyle + : createEmptyStyleDeclaration(); + }; + + for (const installTarget of resolveGlobalScopeInstallTargets(globalScope)) { + installTarget.getComputedStyle = getComputedStyle; + } +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetElementsByClassName.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetElementsByClassName.ts new file mode 100644 index 00000000000..806ff42f848 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetElementsByClassName.ts @@ -0,0 +1,65 @@ +import { isFunction, isNonEmptyString } from '@sniptt/guards'; + +import { type ElementLike } from '@/polyfills/dom/types/ElementLike'; +import { iterateElementSubtree } from '@/polyfills/dom/utils/iterateElementSubtree'; + +const resolveClassNameValue = (element: ElementLike): string | null => { + if (isFunction(element.getAttribute)) { + const classAttribute = element.getAttribute('class'); + + if (isNonEmptyString(classAttribute)) { + return classAttribute; + } + } + + const reflectedClassName = (element as ElementLike & { className?: unknown }) + .className; + + return isNonEmptyString(reflectedClassName) ? reflectedClassName : null; +}; + +const hasEveryClassNameToken = ( + element: ElementLike, + classNameTokens: string[], +): boolean => { + const classNameValue = resolveClassNameValue(element); + + if (!isNonEmptyString(classNameValue)) { + return false; + } + + const elementTokens = classNameValue.split(/\s+/); + + return classNameTokens.every((classNameToken) => + elementTokens.includes(classNameToken), + ); +}; + +export const installGetElementsByClassName = (installTarget: object): void => { + Object.defineProperty(installTarget, 'getElementsByClassName', { + value: function (this: ElementLike, classNames: string) { + const classNameTokens = String(classNames) + .split(/\s+/) + .filter(isNonEmptyString); + + const matches: ElementLike[] = []; + + if (classNameTokens.length > 0) { + for (const currentNode of iterateElementSubtree(this)) { + if ( + currentNode !== this && + hasEveryClassNameToken(currentNode, classNameTokens) + ) { + matches.push(currentNode); + } + } + } + + return Object.assign(matches, { + item: (index: number) => matches[index] ?? null, + }); + }, + configurable: true, + writable: true, + }); +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installLocalStyleOnBaseElements.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installLocalStyleOnBaseElements.ts new file mode 100644 index 00000000000..03d9cd795fb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/installLocalStyleOnBaseElements.ts @@ -0,0 +1,37 @@ +import { isString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { createStyleProxy } from '@/polyfills/dom/utils/createStyleProxy'; + +export const installLocalStyleOnBaseElements = ( + elementPrototype: object, +): void => { + const localStyleDeclarations = new WeakMap>(); + + const resolveLocalStyleDeclaration = ( + element: object, + ): Record => { + const existingDeclaration = localStyleDeclarations.get(element); + + if (isDefined(existingDeclaration)) { + return existingDeclaration; + } + + const declaration = createStyleProxy(() => {}); + localStyleDeclarations.set(element, declaration); + + return declaration; + }; + + Object.defineProperty(elementPrototype, 'style', { + get(this: object) { + return resolveLocalStyleDeclaration(this); + }, + set(this: object, value: unknown) { + if (isString(value)) { + resolveLocalStyleDeclaration(this).cssText = value; + } + }, + configurable: true, + }); +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/iterateElementSubtree.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/iterateElementSubtree.ts new file mode 100644 index 00000000000..7e7ce3bee3c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/iterateElementSubtree.ts @@ -0,0 +1,32 @@ +import { isObject } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { type ElementLike } from '@/polyfills/dom/types/ElementLike'; + +export function* iterateElementSubtree( + rootElement: ElementLike, +): Generator { + const pendingNodes: ElementLike[] = [rootElement]; + + while (pendingNodes.length > 0) { + const currentNode = pendingNodes.pop(); + + if (!isDefined(currentNode)) { + return; + } + + yield currentNode; + + const childNodes = currentNode.childNodes; + + if (isDefined(childNodes)) { + for (let index = childNodes.length - 1; index >= 0; index -= 1) { + const childNode = childNodes[index]; + + if (isObject(childNode)) { + pendingNodes.push(childNode); + } + } + } + } +} diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryObservationLimitWarning.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryObservationLimitWarning.ts new file mode 100644 index 00000000000..0fb527f974e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryObservationLimitWarning.ts @@ -0,0 +1,3 @@ +import { MAX_OBSERVED_GEOMETRY_ELEMENTS } from '@/constants/MaxObservedGeometryElements'; + +export const GEOMETRY_OBSERVATION_LIMIT_WARNING = `[twenty-front-component] Geometry observation limit reached (${MAX_OBSERVED_GEOMETRY_ELEMENTS} elements). getBoundingClientRect and offset/client/scroll metrics will report zero for additional elements. Reduce the number of measured elements, or virtualize long lists.`; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryTransportFailureWarning.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryTransportFailureWarning.ts new file mode 100644 index 00000000000..1b8fd36ba47 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryTransportFailureWarning.ts @@ -0,0 +1,2 @@ +export const GEOMETRY_TRANSPORT_FAILURE_WARNING = + '[twenty-front-component] A geometry bridge request between the host and the worker failed. The geometry mirror may stay stale for the affected elements.'; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/types/GeometryObservationTransport.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/types/GeometryObservationTransport.ts new file mode 100644 index 00000000000..497eb6d6c17 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/types/GeometryObservationTransport.ts @@ -0,0 +1,6 @@ +import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports'; + +export type GeometryObservationTransport = Pick< + FrontComponentHostThreadExports, + 'observeElementGeometry' | 'unobserveElementGeometry' +>; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/types/WorkerGeometryStore.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/types/WorkerGeometryStore.ts new file mode 100644 index 00000000000..4176c9ab777 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/types/WorkerGeometryStore.ts @@ -0,0 +1,12 @@ +import { type GeometryObservationTransport } from '@/polyfills/geometry/types/GeometryObservationTransport'; +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; +import { type GeometryUpdateBatch } from '@/types/GeometryUpdateBatch'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export type WorkerGeometryStore = { + setRootElement: (rootElement: object) => void; + connectTransport: (transport: GeometryObservationTransport) => void; + applyGeometryBatch: (batch: GeometryUpdateBatch) => void; + getViewportSnapshot: () => ViewportGeometrySnapshot | null; + resolveElementSnapshot: (element: object) => ElementGeometrySnapshot | null; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createDomRectFromSnapshot.test.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createDomRectFromSnapshot.test.ts new file mode 100644 index 00000000000..b4ea9fe4315 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createDomRectFromSnapshot.test.ts @@ -0,0 +1,64 @@ +import { createDomRectFromSnapshot } from '../createDomRectFromSnapshot'; + +describe('createDomRectFromSnapshot', () => { + it('should return a zero rect for a null snapshot', () => { + const rect = createDomRectFromSnapshot(null); + + expect(rect.x).toBe(0); + expect(rect.y).toBe(0); + expect(rect.width).toBe(0); + expect(rect.height).toBe(0); + }); + + it('should derive the edge fields from x, y, width and height', () => { + const rect = createDomRectFromSnapshot({ + x: 10, + y: 20, + width: 30, + height: 40, + }); + + expect(rect.top).toBe(20); + expect(rect.left).toBe(10); + expect(rect.right).toBe(40); + expect(rect.bottom).toBe(60); + }); + + it('should return a new object on each call', () => { + const snapshot = { x: 0, y: 0, width: 1, height: 1 }; + + expect(createDomRectFromSnapshot(snapshot)).not.toBe( + createDomRectFromSnapshot(snapshot), + ); + }); + + it('should expose the native toJSON shape when DOMRect is unavailable', () => { + const globalWithDomRect = globalThis as unknown as { + DOMRect: typeof DOMRect | undefined; + }; + const originalDomRect = globalWithDomRect.DOMRect; + globalWithDomRect.DOMRect = undefined; + + try { + const rect = createDomRectFromSnapshot({ + x: 10, + y: 20, + width: 30, + height: 40, + }); + + expect(rect.toJSON()).toEqual({ + x: 10, + y: 20, + width: 30, + height: 40, + top: 20, + left: 10, + right: 40, + bottom: 60, + }); + } finally { + globalWithDomRect.DOMRect = originalDomRect; + } + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createWorkerGeometryStore.test.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createWorkerGeometryStore.test.ts new file mode 100644 index 00000000000..f8fd24aad24 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createWorkerGeometryStore.test.ts @@ -0,0 +1,297 @@ +import { createElementGeometrySnapshotFixture } from '@/__tests__/createElementGeometrySnapshotFixture'; +import { createViewportGeometrySnapshotFixture } from '@/__tests__/createViewportGeometrySnapshotFixture'; +import { GEOMETRY_OBSERVATION_LIMIT_WARNING } from '@/polyfills/geometry/constants/GeometryObservationLimitWarning'; +import { GEOMETRY_TRANSPORT_FAILURE_WARNING } from '@/polyfills/geometry/constants/GeometryTransportFailureWarning'; +import { createWorkerGeometryStore } from '../createWorkerGeometryStore'; + +const remoteElementIdsByElement = new WeakMap(); +let nextRemoteElementId = 0; + +jest.mock('@remote-dom/core/elements', () => ({ + remoteId: (element: object) => { + const existingRemoteElementId = remoteElementIdsByElement.get(element); + + if (existingRemoteElementId !== undefined) { + return existingRemoteElementId; + } + + const remoteElementId = String(nextRemoteElementId); + nextRemoteElementId += 1; + remoteElementIdsByElement.set(element, remoteElementId); + + return remoteElementId; + }, +})); + +const createSnapshot = (width: number) => + createElementGeometrySnapshotFixture({ width }); + +const createViewport = (innerWidth: number) => + createViewportGeometrySnapshotFixture({ innerWidth }); + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +const createRootedStore = () => { + const store = createWorkerGeometryStore(); + const rootElement = { parentNode: null }; + store.setRootElement(rootElement); + + return { store, rootElement }; +}; + +describe('createWorkerGeometryStore', () => { + beforeEach(() => { + nextRemoteElementId = 0; + jest.restoreAllMocks(); + }); + + it('should return null for an element outside the remote root', () => { + const { store } = createRootedStore(); + + expect(store.resolveElementSnapshot({ parentNode: null })).toBeNull(); + }); + + it('should not mint a remote id for an element outside the remote root', async () => { + const { store } = createRootedStore(); + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + + store.resolveElementSnapshot({ parentNode: null }); + await flushMicrotasks(); + + expect(observeElementGeometry).not.toHaveBeenCalled(); + }); + + it('should enroll an element under the remote root and observe it once', async () => { + const { store, rootElement } = createRootedStore(); + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + + const element = { parentNode: rootElement }; + + store.resolveElementSnapshot(element); + store.resolveElementSnapshot(element); + await flushMicrotasks(); + + expect(observeElementGeometry).toHaveBeenCalledTimes(1); + expect(observeElementGeometry).toHaveBeenCalledWith(['0']); + }); + + it('should coalesce a synchronous burst of enrollments into one call', async () => { + const { store, rootElement } = createRootedStore(); + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + + store.resolveElementSnapshot({ parentNode: rootElement }); + store.resolveElementSnapshot({ parentNode: rootElement }); + store.resolveElementSnapshot({ parentNode: rootElement }); + await flushMicrotasks(); + + expect(observeElementGeometry).toHaveBeenCalledTimes(1); + expect(observeElementGeometry).toHaveBeenCalledWith(['0', '1', '2']); + }); + + it('should retain enrollments and flush them when the transport connects later', async () => { + const { store, rootElement } = createRootedStore(); + + store.resolveElementSnapshot({ parentNode: rootElement }); + await flushMicrotasks(); + + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + + expect(observeElementGeometry).toHaveBeenCalledWith(['0']); + }); + + it('should return the snapshot after a batch writes it', () => { + const { store, rootElement } = createRootedStore(); + const element = { parentNode: rootElement }; + + store.resolveElementSnapshot(element); + store.applyGeometryBatch({ elements: { '0': createSnapshot(42) } }); + + expect(store.resolveElementSnapshot(element)?.width).toBe(42); + }); + + it('should merge successive batches rather than replacing the element map', () => { + const { store, rootElement } = createRootedStore(); + const first = { parentNode: rootElement }; + const second = { parentNode: rootElement }; + + store.resolveElementSnapshot(first); + store.resolveElementSnapshot(second); + + store.applyGeometryBatch({ elements: { '0': createSnapshot(1) } }); + store.applyGeometryBatch({ elements: { '1': createSnapshot(2) } }); + + expect(store.resolveElementSnapshot(first)?.width).toBe(1); + expect(store.resolveElementSnapshot(second)?.width).toBe(2); + }); + + it('should delete entries listed in removedRemoteElementIds', () => { + const { store, rootElement } = createRootedStore(); + const element = { parentNode: rootElement }; + + store.resolveElementSnapshot(element); + store.applyGeometryBatch({ elements: { '0': createSnapshot(5) } }); + store.applyGeometryBatch({ removedRemoteElementIds: ['0'] }); + + expect(store.resolveElementSnapshot(element)).toBeNull(); + }); + + it('should replace the viewport snapshot on each batch that carries one', () => { + const { store } = createRootedStore(); + + expect(store.getViewportSnapshot()).toBeNull(); + + store.applyGeometryBatch({ viewport: createViewport(800) }); + expect(store.getViewportSnapshot()?.innerWidth).toBe(800); + + store.applyGeometryBatch({ viewport: createViewport(1200) }); + expect(store.getViewportSnapshot()?.innerWidth).toBe(1200); + }); + + it('should stop enrolling once the observation limit is reached', async () => { + const { store, rootElement } = createRootedStore(); + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + for (let index = 0; index < 600; index += 1) { + store.resolveElementSnapshot({ parentNode: rootElement }); + } + await flushMicrotasks(); + + expect(observeElementGeometry.mock.calls[0][0]).toHaveLength(500); + }); + + it('should warn exactly once about the observation limit', () => { + const { store, rootElement } = createRootedStore(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + for (let index = 0; index < 600; index += 1) { + store.resolveElementSnapshot({ parentNode: rootElement }); + } + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(GEOMETRY_OBSERVATION_LIMIT_WARNING); + }); + + it('should free observation capacity when removedRemoteElementIds prunes ids', async () => { + const { store, rootElement } = createRootedStore(); + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + for (let index = 0; index < 500; index += 1) { + store.resolveElementSnapshot({ parentNode: rootElement }); + } + await flushMicrotasks(); + + store.resolveElementSnapshot({ parentNode: rootElement }); + await flushMicrotasks(); + expect(observeElementGeometry).toHaveBeenCalledTimes(1); + + store.applyGeometryBatch({ removedRemoteElementIds: ['0', '1'] }); + + store.resolveElementSnapshot({ parentNode: rootElement }); + await flushMicrotasks(); + + expect(observeElementGeometry).toHaveBeenCalledTimes(2); + }); + + it('should re-enroll an element whose id was pruned by a removal', async () => { + const { store, rootElement } = createRootedStore(); + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + + const element = { parentNode: rootElement }; + + store.resolveElementSnapshot(element); + await flushMicrotasks(); + + store.applyGeometryBatch({ removedRemoteElementIds: ['0'] }); + + store.resolveElementSnapshot(element); + await flushMicrotasks(); + + expect(observeElementGeometry).toHaveBeenCalledTimes(2); + expect(observeElementGeometry).toHaveBeenLastCalledWith(['0']); + }); + + it('should unobserve pruned ids on the host when a removal batch arrives', async () => { + const { store, rootElement } = createRootedStore(); + const observeElementGeometry = jest.fn().mockResolvedValue(undefined); + const unobserveElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry, + unobserveElementGeometry, + }); + + store.resolveElementSnapshot({ parentNode: rootElement }); + store.resolveElementSnapshot({ parentNode: rootElement }); + await flushMicrotasks(); + + store.applyGeometryBatch({ removedRemoteElementIds: ['0', 'unknown'] }); + + expect(unobserveElementGeometry).toHaveBeenCalledTimes(1); + expect(unobserveElementGeometry).toHaveBeenCalledWith(['0']); + }); + + it('should not call unobserve when a removal batch prunes nothing observed', () => { + const { store } = createRootedStore(); + const unobserveElementGeometry = jest.fn().mockResolvedValue(undefined); + store.connectTransport({ + observeElementGeometry: jest.fn().mockResolvedValue(undefined), + unobserveElementGeometry, + }); + + store.applyGeometryBatch({ removedRemoteElementIds: ['unknown'] }); + + expect(unobserveElementGeometry).not.toHaveBeenCalled(); + }); + + it('should warn once when observeElementGeometry rejects', async () => { + const { store, rootElement } = createRootedStore(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + store.connectTransport({ + observeElementGeometry: jest + .fn() + .mockRejectedValue(new Error('bridge down')), + unobserveElementGeometry: jest.fn().mockResolvedValue(undefined), + }); + + store.resolveElementSnapshot({ parentNode: rootElement }); + await flushMicrotasks(); + + store.resolveElementSnapshot({ parentNode: rootElement }); + await flushMicrotasks(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(GEOMETRY_TRANSPORT_FAILURE_WARNING); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installElementGeometryPolyfill.test.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installElementGeometryPolyfill.test.ts new file mode 100644 index 00000000000..516f0b92491 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installElementGeometryPolyfill.test.ts @@ -0,0 +1,202 @@ +import { createElementGeometrySnapshotFixture } from '@/__tests__/createElementGeometrySnapshotFixture'; +import { createViewportGeometrySnapshotFixture } from '@/__tests__/createViewportGeometrySnapshotFixture'; +import { createWorkerGeometryStoreStub } from '@/__tests__/createWorkerGeometryStoreStub'; +import { type WorkerGeometryStore } from '@/polyfills/geometry/types/WorkerGeometryStore'; +import { installElementGeometryPolyfill } from '../installElementGeometryPolyfill'; + +class FakeElement {} + +type GeometryPolyfilledElement = FakeElement & { + getBoundingClientRect: () => DOMRect; + offsetWidth: number; + offsetHeight: number; + offsetTop: number; + offsetLeft: number; + clientWidth: number; + clientHeight: number; + clientTop: number; + clientLeft: number; + scrollWidth: number; + scrollHeight: number; + scrollTop: number; + scrollLeft: number; + offsetParent: FakeElement | null; +}; + +const asPolyfilled = (element: FakeElement) => + element as GeometryPolyfilledElement; + +const installOn = (geometryStore: WorkerGeometryStore) => { + const documentBody = new FakeElement(); + const documentElement = new FakeElement(); + + installElementGeometryPolyfill({ + elementPrototype: FakeElement.prototype, + documentTarget: { body: documentBody, documentElement }, + geometryStore, + }); + + return { documentBody, documentElement }; +}; + +describe('installElementGeometryPolyfill', () => { + it('should return a zero rect when the store has no snapshot', () => { + installOn(createWorkerGeometryStoreStub()); + + const element = asPolyfilled(new FakeElement()); + + expect(() => element.getBoundingClientRect()).not.toThrow(); + expect(element.getBoundingClientRect().width).toBe(0); + }); + + it('should return the mirrored rect when the store resolves a snapshot', () => { + installOn( + createWorkerGeometryStoreStub({ + resolveElementSnapshot: () => createElementGeometrySnapshotFixture(), + }), + ); + + const rect = asPolyfilled(new FakeElement()).getBoundingClientRect(); + + expect(rect.x).toBe(1); + expect(rect.width).toBe(3); + expect(rect.bottom).toBe(6); + }); + + it('should expose the mirrored numeric metrics when the store resolves a snapshot', () => { + installOn( + createWorkerGeometryStoreStub({ + resolveElementSnapshot: () => createElementGeometrySnapshotFixture(), + }), + ); + + const element = asPolyfilled(new FakeElement()); + + expect(element.offsetWidth).toBe(5); + expect(element.clientHeight).toBe(10); + expect(element.scrollWidth).toBe(13); + expect(element.scrollTop).toBe(15); + }); + + it('should return 0 from every numeric getter when no snapshot exists', () => { + installOn(createWorkerGeometryStoreStub()); + + const element = asPolyfilled(new FakeElement()); + + expect(element.offsetWidth).toBe(0); + expect(element.clientHeight).toBe(0); + expect(element.scrollLeft).toBe(0); + }); + + it('should synthesize body geometry from the viewport root container', () => { + const { documentBody } = installOn( + createWorkerGeometryStoreStub({ + getViewportSnapshot: () => + createViewportGeometrySnapshotFixture({ + rootContainerWidth: 640, + rootContainerHeight: 480, + rootContainerClientWidth: 630, + rootContainerClientHeight: 470, + }), + }), + ); + + const rect = asPolyfilled(documentBody).getBoundingClientRect(); + + expect(rect.width).toBe(640); + expect(rect.height).toBe(480); + expect(asPolyfilled(documentBody).clientWidth).toBe(630); + }); + + it('should place document-scoped rects at the root container host-viewport position', () => { + const { documentBody, documentElement } = installOn( + createWorkerGeometryStoreStub({ + getViewportSnapshot: () => + createViewportGeometrySnapshotFixture({ + rootContainerX: 120, + rootContainerY: 45, + rootContainerWidth: 640, + rootContainerHeight: 480, + }), + }), + ); + + const bodyRect = asPolyfilled(documentBody).getBoundingClientRect(); + const documentElementRect = + asPolyfilled(documentElement).getBoundingClientRect(); + + expect(bodyRect.x).toBe(120); + expect(bodyRect.y).toBe(45); + expect(documentElementRect.x).toBe(120); + expect(documentElementRect.y).toBe(45); + }); + + it('should mirror host scroll offsets on documentElement only', () => { + const { documentBody, documentElement } = installOn( + createWorkerGeometryStoreStub({ + getViewportSnapshot: () => + createViewportGeometrySnapshotFixture({ + scrollX: 30, + scrollY: 700, + }), + }), + ); + + expect(asPolyfilled(documentElement).scrollTop).toBe(700); + expect(asPolyfilled(documentElement).scrollLeft).toBe(30); + expect(asPolyfilled(documentBody).scrollTop).toBe(0); + expect(asPolyfilled(documentBody).scrollLeft).toBe(0); + }); + + it('should return zero document-scoped geometry when no viewport snapshot exists', () => { + const { documentBody } = installOn(createWorkerGeometryStoreStub()); + + expect(asPolyfilled(documentBody).getBoundingClientRect().width).toBe(0); + expect(asPolyfilled(documentBody).clientWidth).toBe(0); + }); + + it('should not throw when scrollTop is assigned and should keep reading the mirrored value', () => { + installOn( + createWorkerGeometryStoreStub({ + resolveElementSnapshot: () => createElementGeometrySnapshotFixture(), + }), + ); + + const element = asPolyfilled(new FakeElement()); + + expect(() => { + element.scrollTop = 999; + }).not.toThrow(); + expect(element.scrollTop).toBe(15); + }); + + it('should return the document body from offsetParent', () => { + const { documentBody } = installOn(createWorkerGeometryStoreStub()); + + expect(asPolyfilled(new FakeElement()).offsetParent).toBe(documentBody); + }); + + it('should return null from offsetParent for the body and document element so offset parent walks terminate', () => { + const { documentBody, documentElement } = installOn( + createWorkerGeometryStoreStub(), + ); + + expect(asPolyfilled(documentBody).offsetParent).toBeNull(); + expect(asPolyfilled(documentElement).offsetParent).toBeNull(); + }); + + it('should return a zero rect when snapshot resolution throws', () => { + installOn( + createWorkerGeometryStoreStub({ + resolveElementSnapshot: () => { + throw new Error('boom'); + }, + }), + ); + + const element = asPolyfilled(new FakeElement()); + + expect(() => element.getBoundingClientRect()).not.toThrow(); + expect(element.getBoundingClientRect().width).toBe(0); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installWindowGeometryPolyfill.test.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installWindowGeometryPolyfill.test.ts new file mode 100644 index 00000000000..fbd886f8780 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installWindowGeometryPolyfill.test.ts @@ -0,0 +1,66 @@ +import { createViewportGeometrySnapshotFixture } from '@/__tests__/createViewportGeometrySnapshotFixture'; +import { createWorkerGeometryStoreStub } from '@/__tests__/createWorkerGeometryStoreStub'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; +import { installWindowGeometryPolyfill } from '../installWindowGeometryPolyfill'; + +const createViewport = (): ViewportGeometrySnapshot => + createViewportGeometrySnapshotFixture({ + innerWidth: 1024, + innerHeight: 768, + devicePixelRatio: 2, + scrollX: 30, + scrollY: 40, + }); + +describe('installWindowGeometryPolyfill', () => { + it('should define the properties on both the global scope and a distinct window', () => { + const polyfillWindow: Record = {}; + const globalScope: Record = { window: polyfillWindow }; + + installWindowGeometryPolyfill({ + globalScope, + geometryStore: createWorkerGeometryStoreStub({ + getViewportSnapshot: createViewport, + }), + }); + + expect(globalScope.innerWidth).toBe(1024); + expect(polyfillWindow.innerWidth).toBe(1024); + expect(polyfillWindow.devicePixelRatio).toBe(2); + expect(globalScope.scrollX).toBe(30); + expect(polyfillWindow.scrollY).toBe(40); + }); + + it('should fall back to zero sizes and a device pixel ratio of one before the first push', () => { + const globalScope: Record = {}; + + installWindowGeometryPolyfill({ + globalScope, + geometryStore: createWorkerGeometryStoreStub(), + }); + + expect(globalScope.innerWidth).toBe(0); + expect(globalScope.innerHeight).toBe(0); + expect(globalScope.devicePixelRatio).toBe(1); + expect(globalScope.scrollX).toBe(0); + expect(globalScope.scrollY).toBe(0); + }); + + it('should reflect a later viewport push without reinstalling', () => { + let viewport: ViewportGeometrySnapshot | null = null; + const globalScope: Record = {}; + + installWindowGeometryPolyfill({ + globalScope, + geometryStore: createWorkerGeometryStoreStub({ + getViewportSnapshot: () => viewport, + }), + }); + + expect(globalScope.innerWidth).toBe(0); + + viewport = createViewport(); + + expect(globalScope.innerWidth).toBe(1024); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/isElementUnderRemoteRoot.test.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/isElementUnderRemoteRoot.test.ts new file mode 100644 index 00000000000..5950143dfc8 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/isElementUnderRemoteRoot.test.ts @@ -0,0 +1,37 @@ +import { isElementUnderRemoteRoot } from '../isElementUnderRemoteRoot'; + +const createNode = (parentNode: object | null = null) => ({ parentNode }); + +describe('isElementUnderRemoteRoot', () => { + it('should return false when there is no root', () => { + expect(isElementUnderRemoteRoot(createNode(), null)).toBe(false); + }); + + it('should return true for the root itself', () => { + const root = createNode(); + + expect(isElementUnderRemoteRoot(root, root)).toBe(true); + }); + + it('should return true for a nested descendant', () => { + const root = createNode(); + const parent = createNode(root); + const child = createNode(parent); + + expect(isElementUnderRemoteRoot(child, root)).toBe(true); + }); + + it('should return false for a detached element', () => { + const root = createNode(); + + expect(isElementUnderRemoteRoot(createNode(), root)).toBe(false); + }); + + it('should return false for a sibling subtree outside the root', () => { + const body = createNode(); + const root = createNode(body); + const outsider = createNode(body); + + expect(isElementUnderRemoteRoot(outsider, root)).toBe(false); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createDomRectFromSnapshot.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createDomRectFromSnapshot.ts new file mode 100644 index 00000000000..0de35f953f0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createDomRectFromSnapshot.ts @@ -0,0 +1,36 @@ +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; + +type RectSnapshot = Pick< + ElementGeometrySnapshot, + 'x' | 'y' | 'width' | 'height' +>; + +export const createDomRectFromSnapshot = ( + snapshot: RectSnapshot | null, +): DOMRect => { + const x = snapshot?.x ?? 0; + const y = snapshot?.y ?? 0; + const width = snapshot?.width ?? 0; + const height = snapshot?.height ?? 0; + + if (typeof DOMRect === 'function') { + return new DOMRect(x, y, width, height); + } + + const top = y; + const left = x; + const right = x + width; + const bottom = y + height; + + return { + x, + y, + width, + height, + top, + left, + right, + bottom, + toJSON: () => ({ x, y, width, height, top, left, right, bottom }), + } as unknown as DOMRect; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createWorkerGeometryStore.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createWorkerGeometryStore.ts new file mode 100644 index 00000000000..0b41d3c68eb --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createWorkerGeometryStore.ts @@ -0,0 +1,151 @@ +import { remoteId } from '@remote-dom/core/elements'; +import { isDefined } from 'twenty-shared/utils'; + +import { MAX_OBSERVED_GEOMETRY_ELEMENTS } from '@/constants/MaxObservedGeometryElements'; +import { GEOMETRY_OBSERVATION_LIMIT_WARNING } from '@/polyfills/geometry/constants/GeometryObservationLimitWarning'; +import { GEOMETRY_TRANSPORT_FAILURE_WARNING } from '@/polyfills/geometry/constants/GeometryTransportFailureWarning'; +import { type GeometryObservationTransport } from '@/polyfills/geometry/types/GeometryObservationTransport'; +import { type WorkerGeometryStore } from '@/polyfills/geometry/types/WorkerGeometryStore'; +import { isElementUnderRemoteRoot } from '@/polyfills/geometry/utils/isElementUnderRemoteRoot'; +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; +import { type GeometryUpdateBatch } from '@/types/GeometryUpdateBatch'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export const createWorkerGeometryStore = (): WorkerGeometryStore => { + const elementSnapshots = new Map(); + const enrolledRemoteElementIds = new WeakMap(); + const observedRemoteElementIds = new Set(); + const pendingObservationIds = new Set(); + + let rootElement: object | null = null; + let transport: GeometryObservationTransport | null = null; + let viewportSnapshot: ViewportGeometrySnapshot | null = null; + let hasScheduledObservationFlush = false; + let hasWarnedAboutObservationLimit = false; + let hasWarnedAboutTransportFailure = false; + + const warnAboutTransportFailure = (): void => { + if (hasWarnedAboutTransportFailure) { + return; + } + + hasWarnedAboutTransportFailure = true; + console.warn(GEOMETRY_TRANSPORT_FAILURE_WARNING); + }; + + const flushPendingObservations = (): void => { + hasScheduledObservationFlush = false; + + if (!isDefined(transport) || pendingObservationIds.size === 0) { + return; + } + + const remoteElementIds = [...pendingObservationIds]; + pendingObservationIds.clear(); + + transport + .observeElementGeometry(remoteElementIds) + .catch(warnAboutTransportFailure); + }; + + const scheduleObservationFlush = (): void => { + if (hasScheduledObservationFlush) { + return; + } + + hasScheduledObservationFlush = true; + queueMicrotask(flushPendingObservations); + }; + + const enrollElement = (element: object): string | null => { + if (observedRemoteElementIds.size >= MAX_OBSERVED_GEOMETRY_ELEMENTS) { + if (!hasWarnedAboutObservationLimit) { + hasWarnedAboutObservationLimit = true; + console.warn(GEOMETRY_OBSERVATION_LIMIT_WARNING); + } + + return null; + } + + const remoteElementId = remoteId(element as Node); + + enrolledRemoteElementIds.set(element, remoteElementId); + observedRemoteElementIds.add(remoteElementId); + pendingObservationIds.add(remoteElementId); + scheduleObservationFlush(); + + return remoteElementId; + }; + + const resolveElementSnapshot = ( + element: object, + ): ElementGeometrySnapshot | null => { + const enrolledRemoteElementId = enrolledRemoteElementIds.get(element); + + if (isDefined(enrolledRemoteElementId)) { + if (observedRemoteElementIds.has(enrolledRemoteElementId)) { + return elementSnapshots.get(enrolledRemoteElementId) ?? null; + } + + enrolledRemoteElementIds.delete(element); + } + + if (!isElementUnderRemoteRoot(element, rootElement)) { + return null; + } + + const remoteElementId = enrollElement(element); + + if (!isDefined(remoteElementId)) { + return null; + } + + return elementSnapshots.get(remoteElementId) ?? null; + }; + + const applyGeometryBatch = (batch: GeometryUpdateBatch): void => { + if (isDefined(batch.viewport)) { + viewportSnapshot = batch.viewport; + } + + if (isDefined(batch.elements)) { + for (const [remoteElementId, snapshot] of Object.entries( + batch.elements, + )) { + elementSnapshots.set(remoteElementId, snapshot); + } + } + + if (isDefined(batch.removedRemoteElementIds)) { + const prunedObservedRemoteElementIds: string[] = []; + + for (const remoteElementId of batch.removedRemoteElementIds) { + elementSnapshots.delete(remoteElementId); + pendingObservationIds.delete(remoteElementId); + + if (observedRemoteElementIds.delete(remoteElementId)) { + prunedObservedRemoteElementIds.push(remoteElementId); + } + } + + if (prunedObservedRemoteElementIds.length > 0 && isDefined(transport)) { + transport + .unobserveElementGeometry(prunedObservedRemoteElementIds) + .catch(warnAboutTransportFailure); + } + } + }; + + return { + setRootElement: (nextRootElement: object) => { + rootElement = nextRootElement; + }, + connectTransport: (nextTransport: GeometryObservationTransport) => { + transport = nextTransport; + flushPendingObservations(); + }, + applyGeometryBatch, + getViewportSnapshot: () => viewportSnapshot, + resolveElementSnapshot, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installElementGeometryPolyfill.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installElementGeometryPolyfill.ts new file mode 100644 index 00000000000..11c694e5a48 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installElementGeometryPolyfill.ts @@ -0,0 +1,131 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type WorkerGeometryStore } from '@/polyfills/geometry/types/WorkerGeometryStore'; +import { createDomRectFromSnapshot } from '@/polyfills/geometry/utils/createDomRectFromSnapshot'; +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; + +const EMPTY_ELEMENT_GEOMETRY_SNAPSHOT: ElementGeometrySnapshot = { + x: 0, + y: 0, + width: 0, + height: 0, + offsetWidth: 0, + offsetHeight: 0, + offsetTop: 0, + offsetLeft: 0, + clientWidth: 0, + clientHeight: 0, + clientTop: 0, + clientLeft: 0, + scrollWidth: 0, + scrollHeight: 0, + scrollTop: 0, + scrollLeft: 0, +}; + +type InstallElementGeometryPolyfillInput = { + elementPrototype: object; + documentTarget: { body?: unknown; documentElement?: unknown }; + geometryStore: WorkerGeometryStore; +}; + +export const installElementGeometryPolyfill = ({ + elementPrototype, + documentTarget, + geometryStore, +}: InstallElementGeometryPolyfillInput): void => { + const isDocumentScopedElement = (element: object): boolean => + element === documentTarget.body || + element === documentTarget.documentElement; + + const resolveDocumentScopedSnapshotInHostViewportFrame = ( + element: object, + ): ElementGeometrySnapshot => { + const viewport = geometryStore.getViewportSnapshot(); + + if (!isDefined(viewport)) { + return EMPTY_ELEMENT_GEOMETRY_SNAPSHOT; + } + + const isDocumentElement = element === documentTarget.documentElement; + + return { + ...EMPTY_ELEMENT_GEOMETRY_SNAPSHOT, + x: viewport.rootContainerX, + y: viewport.rootContainerY, + width: viewport.rootContainerWidth, + height: viewport.rootContainerHeight, + offsetWidth: viewport.rootContainerWidth, + offsetHeight: viewport.rootContainerHeight, + clientWidth: viewport.rootContainerClientWidth, + clientHeight: viewport.rootContainerClientHeight, + scrollWidth: viewport.rootContainerClientWidth, + scrollHeight: viewport.rootContainerClientHeight, + scrollTop: isDocumentElement ? viewport.scrollY : 0, + scrollLeft: isDocumentElement ? viewport.scrollX : 0, + }; + }; + + const resolveSnapshot = (element: object): ElementGeometrySnapshot | null => { + try { + if (isDocumentScopedElement(element)) { + return resolveDocumentScopedSnapshotInHostViewportFrame(element); + } + + return geometryStore.resolveElementSnapshot(element); + } catch { + return null; + } + }; + + Object.defineProperty(elementPrototype, 'getBoundingClientRect', { + value: function (this: object) { + return createDomRectFromSnapshot(resolveSnapshot(this)); + }, + configurable: true, + writable: true, + }); + + const defineGeometryGetter = ( + propertyName: keyof ElementGeometrySnapshot, + { hasNoOpSetter = false }: { hasNoOpSetter?: boolean } = {}, + ): void => { + Object.defineProperty(elementPrototype, propertyName, { + get(this: object) { + return resolveSnapshot(this)?.[propertyName] ?? 0; + }, + ...(hasNoOpSetter && { set() {} }), + configurable: true, + }); + }; + + for (const propertyName of [ + 'offsetWidth', + 'offsetHeight', + 'offsetTop', + 'offsetLeft', + 'clientWidth', + 'clientHeight', + 'clientTop', + 'clientLeft', + 'scrollWidth', + 'scrollHeight', + ] as const) { + defineGeometryGetter(propertyName); + } + + for (const propertyName of ['scrollTop', 'scrollLeft'] as const) { + defineGeometryGetter(propertyName, { hasNoOpSetter: true }); + } + + Object.defineProperty(elementPrototype, 'offsetParent', { + get(this: object) { + if (isDocumentScopedElement(this)) { + return null; + } + + return documentTarget.body ?? null; + }, + configurable: true, + }); +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installWindowGeometryPolyfill.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installWindowGeometryPolyfill.ts new file mode 100644 index 00000000000..1a7b8bc7e0f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installWindowGeometryPolyfill.ts @@ -0,0 +1,35 @@ +import { type WorkerGeometryStore } from '@/polyfills/geometry/types/WorkerGeometryStore'; +import { resolveGlobalScopeInstallTargets } from '@/polyfills/utils/resolveGlobalScopeInstallTargets'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +const VIEWPORT_VALUE_READERS: Record< + string, + (viewport: ViewportGeometrySnapshot | null) => number +> = { + innerWidth: (viewport) => viewport?.innerWidth ?? 0, + innerHeight: (viewport) => viewport?.innerHeight ?? 0, + devicePixelRatio: (viewport) => viewport?.devicePixelRatio ?? 1, + scrollX: (viewport) => viewport?.scrollX ?? 0, + scrollY: (viewport) => viewport?.scrollY ?? 0, +}; + +type InstallWindowGeometryPolyfillInput = { + globalScope: Record; + geometryStore: WorkerGeometryStore; +}; + +export const installWindowGeometryPolyfill = ({ + globalScope, + geometryStore, +}: InstallWindowGeometryPolyfillInput): void => { + for (const installTarget of resolveGlobalScopeInstallTargets(globalScope)) { + for (const [propertyName, readViewportValue] of Object.entries( + VIEWPORT_VALUE_READERS, + )) { + Object.defineProperty(installTarget, propertyName, { + get: () => readViewportValue(geometryStore.getViewportSnapshot()), + configurable: true, + }); + } + } +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/isElementUnderRemoteRoot.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/isElementUnderRemoteRoot.ts new file mode 100644 index 00000000000..07054e7a05a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/utils/isElementUnderRemoteRoot.ts @@ -0,0 +1,27 @@ +import { isObject } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +type NodeLike = { parentNode?: unknown }; + +export const isElementUnderRemoteRoot = ( + element: object, + rootElement: object | null, +): boolean => { + if (!isDefined(rootElement)) { + return false; + } + + let currentNode: object | null = element; + + while (isDefined(currentNode)) { + if (currentNode === rootElement) { + return true; + } + + const parentNode: unknown = (currentNode as NodeLike).parentNode; + + currentNode = isObject(parentNode) ? parentNode : null; + } + + return false; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/geometry/workerGeometryStore.ts b/packages/twenty-front-component-renderer/src/polyfills/geometry/workerGeometryStore.ts new file mode 100644 index 00000000000..b4d167f8816 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/geometry/workerGeometryStore.ts @@ -0,0 +1,3 @@ +import { createWorkerGeometryStore } from '@/polyfills/geometry/utils/createWorkerGeometryStore'; + +export const workerGeometryStore = createWorkerGeometryStore(); diff --git a/packages/twenty-front-component-renderer/src/polyfills/utils/resolveGlobalScopeInstallTargets.ts b/packages/twenty-front-component-renderer/src/polyfills/utils/resolveGlobalScopeInstallTargets.ts new file mode 100644 index 00000000000..2792affe5a5 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/utils/resolveGlobalScopeInstallTargets.ts @@ -0,0 +1,13 @@ +import { isObject } from '@sniptt/guards'; + +export const resolveGlobalScopeInstallTargets = ( + globalScope: Record, +): Record[] => { + const polyfillWindow = globalScope.window; + + if (isObject(polyfillWindow) && polyfillWindow !== globalScope) { + return [globalScope, polyfillWindow as Record]; + } + + return [globalScope]; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentGeometryTrackerEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentGeometryTrackerEffect.tsx new file mode 100644 index 00000000000..11bd9d11444 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentGeometryTrackerEffect.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; + +import { type GeometryTracker } from '@/host/types/GeometryTracker'; +import { GEOMETRY_TRANSPORT_FAILURE_WARNING } from '@/polyfills/geometry/constants/GeometryTransportFailureWarning'; +import { type FrontComponentThread } from '@/types/FrontComponentThread'; + +type FrontComponentGeometryTrackerEffectProps = { + thread: FrontComponentThread; + geometryTracker: GeometryTracker; +}; + +export const FrontComponentGeometryTrackerEffect = ({ + thread, + geometryTracker, +}: FrontComponentGeometryTrackerEffectProps) => { + useEffect(() => { + let hasWarnedAboutGeometryPushFailure = false; + + geometryTracker.setPushGeometryUpdates((batch) => { + thread.imports.pushGeometryUpdates(batch).catch(() => { + if (hasWarnedAboutGeometryPushFailure) { + return; + } + + hasWarnedAboutGeometryPushFailure = true; + console.warn(GEOMETRY_TRANSPORT_FAILURE_WARNING); + }); + }); + + return () => { + geometryTracker.reset(); + }; + }, [thread, geometryTracker]); + + return null; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx index b8c7a284c1a..c03bd42e4e6 100644 --- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx +++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx @@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; import { buildHostFetchPolicyFromFrontComponentUrls } from '@/host/utils/buildHostFetchPolicyFromFrontComponentUrls'; import { createFrontComponentHostThread } from '@/host/utils/createFrontComponentHostThread'; import { createHostFetchEnforcingPolicy } from '@/host/utils/createHostFetchEnforcingPolicy'; +import { type GeometryTracker } from '@/host/types/GeometryTracker'; import { fetchComponentSource } from '@/host/utils/fetchComponentSource'; import { fetchSdkClientSources } from '@/host/utils/fetchSdkClientSources'; import { FRONT_COMPONENT_SANDBOX_DOCUMENT } from '@/remote/sandbox/generated/frontComponentSandboxDocument'; @@ -23,6 +24,7 @@ type FrontComponentWorkerEffectProps = { functionsBaseUrl?: string; sdkClientUrls?: SdkClientUrls; applicationVariables?: Record; + geometryTracker: GeometryTracker; setReceiver: React.Dispatch>; setThread: React.Dispatch>; setError: React.Dispatch>; @@ -35,6 +37,7 @@ export const FrontComponentWorkerEffect = ({ functionsBaseUrl, sdkClientUrls, applicationVariables, + geometryTracker, setReceiver, setThread, setError, @@ -64,7 +67,11 @@ export const FrontComponentWorkerEffect = ({ const hostFetch = createHostFetchEnforcingPolicy(hostFetchPolicy); - const thread = createFrontComponentHostThread(channel.port1, hostFetch); + const thread = createFrontComponentHostThread({ + hostMessagePort: channel.port1, + hostFetch, + geometryTracker, + }); const handleSandboxMessage = createFrontComponentSandboxMessageHandler({ sandboxIframe, @@ -115,6 +122,7 @@ export const FrontComponentWorkerEffect = ({ sdkClientSources, hostFetchOrigins: hostFetchPolicy.allowedOrigins, applicationVariables, + initialViewportGeometry: geometryTracker.getViewportGeometry(), }); } catch (error) { if (!isCancelled) { @@ -143,6 +151,7 @@ export const FrontComponentWorkerEffect = ({ functionsBaseUrl, sdkClientUrls, applicationVariables, + geometryTracker, setError, setReceiver, setThread, diff --git a/packages/twenty-front-component-renderer/src/remote/components/__tests__/FrontComponentGeometryTrackerEffect.test.ts b/packages/twenty-front-component-renderer/src/remote/components/__tests__/FrontComponentGeometryTrackerEffect.test.ts new file mode 100644 index 00000000000..64715c07bae --- /dev/null +++ b/packages/twenty-front-component-renderer/src/remote/components/__tests__/FrontComponentGeometryTrackerEffect.test.ts @@ -0,0 +1,116 @@ +import '../../../host/utils/__tests__/setupServerRenderingGlobals'; + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { createStubGeometryTracker } from '@/__tests__/createStubGeometryTracker'; +import { type FrontComponentThread } from '@/types/FrontComponentThread'; +import { FrontComponentGeometryTrackerEffect } from '../FrontComponentGeometryTrackerEffect'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const createStubThread = () => + ({ + imports: { pushGeometryUpdates: jest.fn().mockResolvedValue(undefined) }, + }) as unknown as FrontComponentThread; + +describe('FrontComponentGeometryTrackerEffect', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + container.remove(); + }); + + it('should arm the tracker on mount', () => { + const geometryTracker = createStubGeometryTracker(); + const thread = createStubThread(); + + act(() => { + root.render( + createElement(FrontComponentGeometryTrackerEffect, { + thread, + geometryTracker, + }), + ); + }); + + expect(geometryTracker.setPushGeometryUpdates).toHaveBeenCalledTimes(1); + }); + + it('should forward a pushed batch to the worker thread', () => { + const geometryTracker = createStubGeometryTracker(); + const thread = createStubThread(); + + act(() => { + root.render( + createElement(FrontComponentGeometryTrackerEffect, { + thread, + geometryTracker, + }), + ); + }); + + const pushGeometryUpdates = ( + geometryTracker.setPushGeometryUpdates as jest.Mock + ).mock.calls[0][0]; + + pushGeometryUpdates({ elements: {} }); + + expect(thread.imports.pushGeometryUpdates).toHaveBeenCalledWith({ + elements: {}, + }); + }); + + it('should reset the tracker on unmount', () => { + const geometryTracker = createStubGeometryTracker(); + + act(() => { + root.render( + createElement(FrontComponentGeometryTrackerEffect, { + thread: createStubThread(), + geometryTracker, + }), + ); + }); + + act(() => { + root.unmount(); + }); + + expect(geometryTracker.reset).toHaveBeenCalledTimes(1); + }); + + it('should re-arm when the thread identity changes', () => { + const geometryTracker = createStubGeometryTracker(); + + act(() => { + root.render( + createElement(FrontComponentGeometryTrackerEffect, { + thread: createStubThread(), + geometryTracker, + }), + ); + }); + + act(() => { + root.render( + createElement(FrontComponentGeometryTrackerEffect, { + thread: createStubThread(), + geometryTracker, + }), + ); + }); + + expect(geometryTracker.setPushGeometryUpdates).toHaveBeenCalledTimes(2); + expect(geometryTracker.reset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts index 86a27d3b0d0..63fcaca95d2 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts @@ -9,6 +9,13 @@ import { isDefined } from 'twenty-shared/utils'; import { frontComponentHostCommunicationApi } from '@/constants/frontComponentHostCommunicationApi'; import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/constants/HtmlTagToRemoteComponent'; +import { installDocumentGetElementById } from '@/polyfills/dom/utils/installDocumentGetElementById'; +import { installGetComputedStyle } from '@/polyfills/dom/utils/installGetComputedStyle'; +import { installGetElementsByClassName } from '@/polyfills/dom/utils/installGetElementsByClassName'; +import { installLocalStyleOnBaseElements } from '@/polyfills/dom/utils/installLocalStyleOnBaseElements'; +import { workerGeometryStore } from '@/polyfills/geometry/workerGeometryStore'; +import { installElementGeometryPolyfill } from '@/polyfills/geometry/utils/installElementGeometryPolyfill'; +import { installWindowGeometryPolyfill } from '@/polyfills/geometry/utils/installWindowGeometryPolyfill'; import { exposeGlobals } from '@/remote/utils/exposeGlobals'; import { installStylePropertyOnRemoteElements } from '@/remote/utils/installStylePropertyOnRemoteElements'; import { patchRemoteElementAttributes } from '@/remote/utils/patchRemoteElementAttributes'; @@ -26,6 +33,24 @@ installStylePropertyOnRemoteElements(); patchRemoteElementAttributes(); installErrorEventBridge(); +installDocumentGetElementById(document); +installGetElementsByClassName(Element.prototype); +installGetElementsByClassName(document); +installLocalStyleOnBaseElements(Element.prototype); + +installGetComputedStyle(globalThis as unknown as Record); + +installElementGeometryPolyfill({ + elementPrototype: Element.prototype, + documentTarget: document, + geometryStore: workerGeometryStore, +}); + +installWindowGeometryPolyfill({ + globalScope: globalThis as unknown as Record, + geometryStore: workerGeometryStore, +}); + exposeGlobals({ __HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG, }); @@ -58,6 +83,9 @@ const workerExports: WorkerExports = { onConfirmationModalResult: async (result) => { await handleCommandConfirmationModalResult(result); }, + pushGeometryUpdates: async (batch) => { + workerGeometryStore.applyGeometryBatch(batch); + }, }; self.addEventListener('message', (event) => { @@ -67,13 +95,16 @@ self.addEventListener('message', (event) => { return; } - hostThread = new ThreadMessagePort< + const nextHostThread = new ThreadMessagePort< FrontComponentHostThreadExports, WorkerExports >(transferredPort, { exports: workerExports, serialization: createClonableErrorThreadSerialization(), }); + hostThread = nextHostThread; + + workerGeometryStore.connectTransport(nextHostThread.imports); transferredPort.start(); }); diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts index 693c5287800..702f46ed60f 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/attachRemoteRenderRootToWorkerDocument.ts @@ -4,6 +4,7 @@ import { type RemoteRootElement, } from '@remote-dom/core/elements'; +import { workerGeometryStore } from '@/polyfills/geometry/workerGeometryStore'; import { installStyleBridge } from '@/polyfills/installStyleBridge'; export const attachRemoteRenderRootToWorkerDocument = ( @@ -16,6 +17,7 @@ export const attachRemoteRenderRootToWorkerDocument = ( remoteRoot.connect(batchedConnection); remoteRoot.append(renderContainer); document.body.append(remoteRoot); + workerGeometryStore.setRootElement(remoteRoot); installStyleBridge(remoteRoot); return renderContainer; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts index 64d881ff6af..d3c9c649c6a 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts @@ -1,6 +1,7 @@ import { type RemoteConnection } from '@remote-dom/core/elements'; import { CustomError, isDefined } from 'twenty-shared/utils'; +import { workerGeometryStore } from '@/polyfills/geometry/workerGeometryStore'; import { attachRemoteRenderRootToWorkerDocument } from '@/remote/worker/utils/attachRemoteRenderRootToWorkerDocument'; import { installHostFetchProxy } from '@/remote/worker/utils/installHostFetchProxy'; import { loadFrontComponentModule } from '@/remote/worker/utils/loadFrontComponentModule'; @@ -32,6 +33,12 @@ export const renderFrontComponent = async ({ setWorkerEnvironmentVariablesFromRenderContext(renderContext); + if (isDefined(renderContext.initialViewportGeometry)) { + workerGeometryStore.applyGeometryBatch({ + viewport: renderContext.initialViewportGeometry, + }); + } + const componentModule = await loadFrontComponentModule({ componentSource: renderContext.componentSource, sdkClientSources: renderContext.sdkClientSources, diff --git a/packages/twenty-front-component-renderer/src/types/ElementGeometrySnapshot.ts b/packages/twenty-front-component-renderer/src/types/ElementGeometrySnapshot.ts new file mode 100644 index 00000000000..ef21c8606d5 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/ElementGeometrySnapshot.ts @@ -0,0 +1,18 @@ +export type ElementGeometrySnapshot = { + x: number; + y: number; + width: number; + height: number; + offsetWidth: number; + offsetHeight: number; + offsetTop: number; + offsetLeft: number; + clientWidth: number; + clientHeight: number; + clientTop: number; + clientLeft: number; + scrollWidth: number; + scrollHeight: number; + scrollTop: number; + scrollLeft: number; +}; diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts index db0669db420..b87ee7715ce 100644 --- a/packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentHostThreadExports.ts @@ -4,4 +4,6 @@ import { type HostFetchFunction } from '@/types/HostFetchFunction'; export type FrontComponentHostThreadExports = FrontComponentHostCommunicationApi & { hostFetch: HostFetchFunction; + observeElementGeometry: (remoteElementIds: string[]) => Promise; + unobserveElementGeometry: (remoteElementIds: string[]) => Promise; }; diff --git a/packages/twenty-front-component-renderer/src/types/GeometryUpdateBatch.ts b/packages/twenty-front-component-renderer/src/types/GeometryUpdateBatch.ts new file mode 100644 index 00000000000..adf9fb71b2c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/GeometryUpdateBatch.ts @@ -0,0 +1,8 @@ +import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; + +export type GeometryUpdateBatch = { + viewport?: ViewportGeometrySnapshot; + elements?: Record; + removedRemoteElementIds?: string[]; +}; diff --git a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts index f0b91c27dda..b69adf8aedf 100644 --- a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts +++ b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts @@ -1,4 +1,5 @@ import { type SdkClientSources } from '@/types/SdkClientSources'; +import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot'; export type HostToWorkerRenderContext = { componentUrl: string; @@ -9,4 +10,5 @@ export type HostToWorkerRenderContext = { sdkClientSources?: SdkClientSources; hostFetchOrigins?: string[]; applicationVariables?: Record; + initialViewportGeometry?: ViewportGeometrySnapshot; }; diff --git a/packages/twenty-front-component-renderer/src/types/ViewportGeometrySnapshot.ts b/packages/twenty-front-component-renderer/src/types/ViewportGeometrySnapshot.ts new file mode 100644 index 00000000000..38af73fe37d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/ViewportGeometrySnapshot.ts @@ -0,0 +1,13 @@ +export type ViewportGeometrySnapshot = { + innerWidth: number; + innerHeight: number; + devicePixelRatio: number; + scrollX: number; + scrollY: number; + rootContainerX: number; + rootContainerY: number; + rootContainerWidth: number; + rootContainerHeight: number; + rootContainerClientWidth: number; + rootContainerClientHeight: number; +}; diff --git a/packages/twenty-front-component-renderer/src/types/WorkerExports.ts b/packages/twenty-front-component-renderer/src/types/WorkerExports.ts index 59a06529b68..0ca200c0b29 100644 --- a/packages/twenty-front-component-renderer/src/types/WorkerExports.ts +++ b/packages/twenty-front-component-renderer/src/types/WorkerExports.ts @@ -1,6 +1,7 @@ import { type RemoteConnection } from '@remote-dom/core/elements'; import { type CommandConfirmationModalResult } from 'twenty-sdk/front-component'; import { type FrontComponentExecutionContext } from './FrontComponentExecutionContext'; +import { type GeometryUpdateBatch } from './GeometryUpdateBatch'; import { type HostToWorkerRenderContext } from './HostToWorkerRenderContext'; export type WorkerExports = { @@ -13,4 +14,5 @@ export type WorkerExports = { onConfirmationModalResult: ( result: CommandConfirmationModalResult, ) => Promise; + pushGeometryUpdates: (batch: GeometryUpdateBatch) => Promise; }; diff --git a/yarn.lock b/yarn.lock index ab6babcfd49..6be4e457b4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15514,6 +15514,28 @@ __metadata: languageName: node linkType: hard +"@reduxjs/toolkit@npm:^1.9.0 || 2.x.x": + version: 2.12.0 + resolution: "@reduxjs/toolkit@npm:2.12.0" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@standard-schema/utils": "npm:^0.3.0" + immer: "npm:^11.0.0" + redux: "npm:^5.0.1" + redux-thunk: "npm:^3.1.0" + reselect: "npm:^5.1.0" + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 10c0/2b85e31294a7139994fd78b08eb3ce706ce3b03b5081c13403b93ba4883a152d637171e4d9d969766238851f8915b1daa9f36b5a0a458aff0ff7600ee38795c9 + languageName: node + linkType: hard + "@remirror/core-constants@npm:3.0.0": version: 3.0.0 resolution: "@remirror/core-constants@npm:3.0.0" @@ -18180,7 +18202,7 @@ __metadata: languageName: node linkType: hard -"@standard-schema/spec@npm:^1.1.0": +"@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 @@ -19880,6 +19902,13 @@ __metadata: languageName: node linkType: hard +"@types/d3-array@npm:^3.0.3": + version: 3.2.2 + resolution: "@types/d3-array@npm:3.2.2" + checksum: 10c0/6137cb97302f8a4f18ca22c0560c585cfcb823f276b23d89f2c0c005d72697ec13bca671c08e68b4b0cabd622e3f0e91782ee221580d6774074050be96dd7028 + languageName: node + linkType: hard + "@types/d3-color@npm:*, @types/d3-color@npm:^3.0.0": version: 3.1.3 resolution: "@types/d3-color@npm:3.1.3" @@ -19903,6 +19932,13 @@ __metadata: languageName: node linkType: hard +"@types/d3-ease@npm:^3.0.0": + version: 3.0.2 + resolution: "@types/d3-ease@npm:3.0.2" + checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c + languageName: node + linkType: hard + "@types/d3-format@npm:^1.4.1": version: 1.4.5 resolution: "@types/d3-format@npm:1.4.5" @@ -19910,7 +19946,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.4": +"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.1, @types/d3-interpolate@npm:^3.0.4": version: 3.0.4 resolution: "@types/d3-interpolate@npm:3.0.4" dependencies: @@ -19933,6 +19969,15 @@ __metadata: languageName: node linkType: hard +"@types/d3-scale@npm:^4.0.2": + version: 4.0.9 + resolution: "@types/d3-scale@npm:4.0.9" + dependencies: + "@types/d3-time": "npm:*" + checksum: 10c0/4ac44233c05cd50b65b33ecb35d99fdf07566bcdbc55bc1306b2f27d1c5134d8c560d356f2c8e76b096e9125ffb8d26d95f78d56e210d1c542cb255bdf31d6c8 + languageName: node + linkType: hard + "@types/d3-scale@npm:^4.0.8": version: 4.0.8 resolution: "@types/d3-scale@npm:4.0.8" @@ -19949,6 +19994,15 @@ __metadata: languageName: node linkType: hard +"@types/d3-shape@npm:^3.1.0": + version: 3.1.8 + resolution: "@types/d3-shape@npm:3.1.8" + dependencies: + "@types/d3-path": "npm:*" + checksum: 10c0/49ec2172b9eb66fc1d036e2a23966216bb972e9af51ddbed134df24bd71aedf207bb1ef81903a119eb4e1f5e927cf44beacaf64c9af86474e5548594b102b574 + languageName: node + linkType: hard + "@types/d3-shape@npm:^3.1.6": version: 3.1.6 resolution: "@types/d3-shape@npm:3.1.6" @@ -19986,6 +20040,20 @@ __metadata: languageName: node linkType: hard +"@types/d3-time@npm:^3.0.0": + version: 3.0.4 + resolution: "@types/d3-time@npm:3.0.4" + checksum: 10c0/6d9e2255d63f7a313a543113920c612e957d70da4fb0890931da6c2459010291b8b1f95e149a538500c1c99e7e6c89ffcce5554dd29a31ff134a38ea94b6d174 + languageName: node + linkType: hard + +"@types/d3-timer@npm:^3.0.0": + version: 3.0.2 + resolution: "@types/d3-timer@npm:3.0.2" + checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1 + languageName: node + linkType: hard + "@types/d3-transition@npm:^3.0.8": version: 3.0.8 resolution: "@types/d3-transition@npm:3.0.8" @@ -27780,7 +27848,7 @@ __metadata: languageName: node linkType: hard -"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3": +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6": version: 3.2.4 resolution: "d3-array@npm:3.2.4" dependencies: @@ -27822,7 +27890,7 @@ __metadata: languageName: node linkType: hard -"d3-ease@npm:1 - 3": +"d3-ease@npm:1 - 3, d3-ease@npm:^3.0.1": version: 3.0.1 resolution: "d3-ease@npm:3.0.1" checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0 @@ -27889,7 +27957,7 @@ __metadata: languageName: node linkType: hard -"d3-shape@npm:^3.2.0": +"d3-shape@npm:^3.1.0, d3-shape@npm:^3.2.0": version: 3.2.0 resolution: "d3-shape@npm:3.2.0" dependencies: @@ -27925,7 +27993,7 @@ __metadata: languageName: node linkType: hard -"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3": +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0": version: 3.1.0 resolution: "d3-time@npm:3.1.0" dependencies: @@ -27941,7 +28009,7 @@ __metadata: languageName: node linkType: hard -"d3-timer@npm:1 - 3": +"d3-timer@npm:1 - 3, d3-timer@npm:^3.0.1": version: 3.0.1 resolution: "d3-timer@npm:3.0.1" checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a @@ -28314,6 +28382,13 @@ __metadata: languageName: node linkType: hard +"decimal.js-light@npm:^2.5.1": + version: 2.5.1 + resolution: "decimal.js-light@npm:2.5.1" + checksum: 10c0/4fd33f535aac9e5bd832796831b65d9ec7914ad129c7437b3ab991b0c2eaaa5a57e654e6174c4a17f1b3895ea366f0c1ab4955cdcdf7cfdcf3ad5a58b456c020 + languageName: node + linkType: hard + "decimal.js@npm:^10.4.3, decimal.js@npm:^10.5.0": version: 10.5.0 resolution: "decimal.js@npm:10.5.0" @@ -29610,6 +29685,20 @@ __metadata: languageName: node linkType: hard +"es-toolkit@npm:^1.39.3": + version: 1.50.0 + resolution: "es-toolkit@npm:1.50.0" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + vitepress-plugin-sandpack@1.1.4: + unplugged: true + checksum: 10c0/80e3a212d337df6f20ed0330d1b62dda25f3ef6d4c83a33f13d521ccbf5b4ad1ac3e7e638572abd6080627f522707de76a5c9360350b713e5392739083ee1f24 + languageName: node + linkType: hard + "esast-util-from-estree@npm:^2.0.0": version: 2.0.0 resolution: "esast-util-from-estree@npm:2.0.0" @@ -33498,6 +33587,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^11.0.0, immer@npm:^11.1.8": + version: 11.1.15 + resolution: "immer@npm:11.1.15" + checksum: 10c0/5215eaec9641ad786ae8940a489514c26ce3726f8c49f0191a754902800f3ddd102b0653e894cabe7049d93e77b86d67d19953e778ffebba56848739a31b3b79 + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -43765,6 +43861,25 @@ __metadata: languageName: node linkType: hard +"react-redux@npm:8.x.x || 9.x.x": + version: 9.3.0 + resolution: "react-redux@npm:9.3.0" + dependencies: + "@types/use-sync-external-store": "npm:^0.0.6" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + "@types/react": ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + redux: + optional: true + checksum: 10c0/b9f4efcfbfbc90cac9d1709ab3affb1e18a9dc9bd3cceda43bd2e1d9d2394ee0c29df36ec2202d52b566db774888b594c8c5aa86b64f27ef34fca607c687c9e3 + languageName: node + linkType: hard + "react-remove-scroll-bar@npm:^2.3.7": version: 2.3.8 resolution: "react-remove-scroll-bar@npm:2.3.8" @@ -44094,6 +44209,29 @@ __metadata: languageName: node linkType: hard +"recharts@npm:^3.9.2": + version: 3.10.0 + resolution: "recharts@npm:3.10.0" + dependencies: + "@reduxjs/toolkit": "npm:^1.9.0 || 2.x.x" + clsx: "npm:^2.1.1" + decimal.js-light: "npm:^2.5.1" + es-toolkit: "npm:^1.39.3" + eventemitter3: "npm:^5.0.1" + immer: "npm:^11.1.8" + react-redux: "npm:8.x.x || 9.x.x" + reselect: "npm:5.2.0" + tiny-invariant: "npm:^1.3.3" + use-sync-external-store: "npm:^1.2.2" + victory-vendor: "npm:^37.0.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/223524f5db2bb660878c37d70a441b899e9db861dd47768ccccd3982b43d0233cbc3b4a01d32abb76d8782320839cf4a3dc3de9bc002f7c9177b656484efe506 + languageName: node + linkType: hard + "recma-build-jsx@npm:^1.0.0": version: 1.0.0 resolution: "recma-build-jsx@npm:1.0.0" @@ -44184,6 +44322,22 @@ __metadata: languageName: node linkType: hard +"redux-thunk@npm:^3.1.0": + version: 3.1.0 + resolution: "redux-thunk@npm:3.1.0" + peerDependencies: + redux: ^5.0.0 + checksum: 10c0/21557f6a30e1b2e3e470933247e51749be7f1d5a9620069a3125778675ce4d178d84bdee3e2a0903427a5c429e3aeec6d4df57897faf93eb83455bc1ef7b66fd + languageName: node + linkType: hard + +"redux@npm:^5.0.1": + version: 5.0.1 + resolution: "redux@npm:5.0.1" + checksum: 10c0/b10c28357194f38e7d53b760ed5e64faa317cc63de1fb95bc5d9e127fab956392344368c357b8e7a9bedb0c35b111e7efa522210cfdc3b3c75e5074718e9069c + languageName: node + linkType: hard + "reflect-metadata@npm:0.2.2, reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -44671,6 +44825,13 @@ __metadata: languageName: node linkType: hard +"reselect@npm:5.2.0, reselect@npm:^5.1.0": + version: 5.2.0 + resolution: "reselect@npm:5.2.0" + checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e + languageName: node + linkType: hard + "reselect@npm:^4.1.7": version: 4.1.8 resolution: "reselect@npm:4.1.8" @@ -48940,6 +49101,7 @@ __metadata: react: "npm:^19.2.0" react-dom: "npm:^19.2.0" react-error-boundary: "npm:^4.0.11" + recharts: "npm:^3.9.2" storybook: "npm:^10.4.6" styled-components: "npm:^6.1.0" ts-morph: "npm:^25.0.0" @@ -50784,7 +50946,7 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:1.6.0, use-sync-external-store@npm:^1.5.0, use-sync-external-store@npm:^1.6.0": +"use-sync-external-store@npm:1.6.0, use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.5.0, use-sync-external-store@npm:^1.6.0": version: 1.6.0 resolution: "use-sync-external-store@npm:1.6.0" peerDependencies: @@ -51038,6 +51200,28 @@ __metadata: languageName: node linkType: hard +"victory-vendor@npm:^37.0.2": + version: 37.3.6 + resolution: "victory-vendor@npm:37.3.6" + dependencies: + "@types/d3-array": "npm:^3.0.3" + "@types/d3-ease": "npm:^3.0.0" + "@types/d3-interpolate": "npm:^3.0.1" + "@types/d3-scale": "npm:^4.0.2" + "@types/d3-shape": "npm:^3.1.0" + "@types/d3-time": "npm:^3.0.0" + "@types/d3-timer": "npm:^3.0.0" + d3-array: "npm:^3.1.6" + d3-ease: "npm:^3.0.1" + d3-interpolate: "npm:^3.0.1" + d3-scale: "npm:^4.0.2" + d3-shape: "npm:^3.1.0" + d3-time: "npm:^3.0.0" + d3-timer: "npm:^3.0.1" + checksum: 10c0/0a9628db30b898160409628f26d457ba15adc86472531817d73fbeb847de498d94f91dee5f4caa969195744e91be93e100eeff7a392b591ac5349343a36dec29 + languageName: node + linkType: hard + "vinyl-file@npm:^5.0.0": version: 5.0.0 resolution: "vinyl-file@npm:5.0.0"