From 014b3cdc675cdc0af9ec9e3d25f5cefdecab13aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:02:18 +0200 Subject: [PATCH] Mirror host element geometry into front component workers (#23264) Front components run in a Web Worker whose fake DOM has no layout APIs, so any library that measures itself crashes. This is the reported recharts bug: `ref.getBoundingClientRect is not a function`. ### Why this is needed Layout only exists on the host: the worker builds a virtual tree, and the host renders the real DOM nodes. Nothing in the worker knows how big anything is. ```mermaid flowchart LR COMP["Front component
recharts, twenty-ui"] -->|"el.getBoundingClientRect()"| DOM["remote-dom fake DOM
in the Web Worker"] DOM --> MISS["No layout APIs:
method does not exist"] MISS --> BOOM["TypeError, component crashes"] HOST["Host: real DOM nodes
with real sizes"] -.->|"never reaches the worker"| DOM ``` The worker cannot simply ask the host and wait: measurement APIs are synchronous, and the worker must never block on a round trip. ### How the mirror works The host measures and pushes; the worker only ever reads from a local copy. Reads stay synchronous and are at most one frame behind. ```mermaid flowchart TB subgraph HOST["Host - main thread, real DOM"] WAKE["Wake sources
resize, scroll, mutations, animation events"] TRACK["createGeometryTracker
rAF loop, idles after 20 unchanged frames"] NODES["Real DOM nodes
registered per remote element id"] end subgraph WORKER["Web Worker - fake DOM"] STORE["workerGeometryStore
snapshot mirror"] POLY["Element.prototype polyfill
getBoundingClientRect, offset, client, scroll"] COMP2["Front component"] end WAKE -->|"wake"| TRACK NODES -->|"measure changed nodes only"| TRACK TRACK ==>|"pushGeometryUpdates over MessagePort"| STORE STORE -->|"synchronous read, one frame stale"| POLY POLY --> COMP2 COMP2 -.->|"first read enrolls the element:
observeElementGeometry"| TRACK ``` Enrollment is demand-driven: an element is only measured once the component actually reads its geometry, so idle components cost nothing. ```mermaid sequenceDiagram participant C as Front component participant P as Element polyfill participant S as Worker geometry store participant T as Host geometry tracker participant D as Real DOM C->>P: el.getBoundingClientRect P->>S: resolve snapshot S-->>P: none yet, returns zeros S->>T: observeElementGeometry, batched in a microtask T->>D: measure on the next animation frame D-->>T: rect, offset, client, scroll T->>S: pushGeometryUpdates with viewport and changed elements Note over T: the loop stops after 20 unchanged frames, any wake source restarts it C->>P: el.getBoundingClientRect on a later frame P->>S: resolve snapshot S-->>P: mirrored values P-->>C: real numbers ``` ### What changed - The host measures the real DOM nodes on animation frames while wake sources report activity, and pushes snapshots over the existing MessagePort. The loop goes idle when nothing changes, and both sides cap observation at 500 elements. - In the worker, `getBoundingClientRect`, the `offset*`/`client*`/`scroll*` getters and `window.innerWidth`/`innerHeight` read those snapshots from the worker-local mirror. - The worker also gains the small DOM APIs libraries expect: `getComputedStyle` (returns the element's declared style), `getElementsByClassName`, `document.getElementById`, and a working per-element `style` on base elements (remote-dom ships a no-op stub whose `getPropertyValue` returns undefined, which crashed twenty-ui's ThemeProvider). Result: a fixed-size recharts `AreaChart` story renders, and the four twenty-ui gallery stories that used to fail on the missing `getComputedStyle` now run in strict zero-failure mode. Moved, not new: `FrontComponentRenderer` now renders its thread effects directly instead of through a pass-through component, and its output is wrapped in a `
` instead of a fragment so geometry has a measurable root (a real layout change for embedders). Deferred to the ResizeObserver follow-up: text measurement (axis-label overlap thinning), `offsetParent` mirroring, animation in-flight tracking, `ResponsiveContainer`, the tooltip, and the `measureElementGeometry` RPC. Last of the three PRs splitting the geometry mirror work, after #23262 (host wrapper hooks) and #23263 (style proxy). --- .../package.json | 1 + .../src/__stories__/Geometry.stories.tsx | 94 +++ .../__stories__/TwentyUiGallery.stories.tsx | 18 +- .../geometry-measure.front-component.tsx | 75 ++ .../recharts-example.front-component.tsx | 38 + .../createElementGeometrySnapshotFixture.ts | 23 + .../__tests__/createStubGeometryTracker.ts | 13 + .../createViewportGeometrySnapshotFixture.ts | 18 + .../createWorkerGeometryStoreStub.ts | 12 + .../constants/MaxObservedGeometryElements.ts | 1 + .../components/FrontComponentRenderer.tsx | 136 ++-- .../host/constants/GeometryEpsilonPixels.ts | 1 + .../constants/GeometryIdleFrameThreshold.ts | 1 + ...etryUnregisteredObservationExpiryFrames.ts | 1 + .../src/host/constants/RootContainerStyle.ts | 6 + .../FrontComponentGeometryTrackerContext.ts | 6 + .../__tests__/useGeometryNodeRef.test.tsx | 70 ++ .../src/host/hooks/useGeometryNodeRef.ts | 39 + .../src/host/hooks/useHtmlHostElementProps.ts | 7 + .../src/host/types/GeometryTracker.ts | 16 + .../src/host/types/GeometryWakeSources.ts | 9 + .../src/host/types/PushGeometryUpdates.ts | 3 + .../__tests__/createGeometryTracker.test.ts | 728 ++++++++++++++++++ .../__tests__/createHtmlHostWrapper.test.ts | 61 +- ...tmlHostWrapperGeometryRegistration.test.ts | 145 ++++ .../getRemoteElementIdFromProps.test.ts | 27 + ...GeometrySnapshotEqualWithinEpsilon.test.ts | 53 ++ .../__tests__/measureNodeGeometry.test.ts | 103 +++ .../__tests__/measureViewportGeometry.test.ts | 59 ++ .../sanitizeRemoteElementIds.test.ts | 24 + .../utils/__tests__/setupGeometryGlobals.ts | 111 +++ .../utils/createFrontComponentHostThread.ts | 22 +- .../src/host/utils/createGeometryTracker.ts | 275 +++++++ .../host/utils/createGeometryWakeSources.ts | 223 ++++++ .../host/utils/getRemoteElementIdFromProps.ts | 18 + .../isGeometrySnapshotEqualWithinEpsilon.ts | 25 + .../src/host/utils/measureNodeGeometry.ts | 37 + .../src/host/utils/measureViewportGeometry.ts | 25 + .../host/utils/sanitizeRemoteElementIds.ts | 11 + .../src/index.ts | 3 + .../src/polyfills/dom/types/ElementLike.ts | 4 + .../polyfills/dom/types/ElementWithStyle.ts | 3 + .../installDocumentGetElementById.test.ts | 80 ++ .../__tests__/installGetComputedStyle.test.ts | 62 ++ .../installGetElementsByClassName.test.ts | 138 ++++ .../installLocalStyleOnBaseElements.test.ts | 78 ++ .../utils/installDocumentGetElementById.ts | 33 + .../dom/utils/installGetComputedStyle.ts | 30 + .../utils/installGetElementsByClassName.ts | 65 ++ .../utils/installLocalStyleOnBaseElements.ts | 37 + .../dom/utils/iterateElementSubtree.ts | 32 + .../GeometryObservationLimitWarning.ts | 3 + .../GeometryTransportFailureWarning.ts | 2 + .../types/GeometryObservationTransport.ts | 6 + .../geometry/types/WorkerGeometryStore.ts | 12 + .../createDomRectFromSnapshot.test.ts | 64 ++ .../createWorkerGeometryStore.test.ts | 297 +++++++ .../installElementGeometryPolyfill.test.ts | 202 +++++ .../installWindowGeometryPolyfill.test.ts | 66 ++ .../isElementUnderRemoteRoot.test.ts | 37 + .../utils/createDomRectFromSnapshot.ts | 36 + .../utils/createWorkerGeometryStore.ts | 151 ++++ .../utils/installElementGeometryPolyfill.ts | 131 ++++ .../utils/installWindowGeometryPolyfill.ts | 35 + .../utils/isElementUnderRemoteRoot.ts | 27 + .../polyfills/geometry/workerGeometryStore.ts | 3 + .../utils/resolveGlobalScopeInstallTargets.ts | 13 + .../FrontComponentGeometryTrackerEffect.tsx | 36 + .../components/FrontComponentWorkerEffect.tsx | 11 +- ...rontComponentGeometryTrackerEffect.test.ts | 116 +++ .../src/remote/worker/remote-worker.ts | 33 +- .../attachRemoteRenderRootToWorkerDocument.ts | 2 + .../worker/utils/renderFrontComponent.ts | 7 + .../src/types/ElementGeometrySnapshot.ts | 18 + .../types/FrontComponentHostThreadExports.ts | 2 + .../src/types/GeometryUpdateBatch.ts | 8 + .../src/types/HostToWorkerRenderContext.ts | 2 + .../src/types/ViewportGeometrySnapshot.ts | 13 + .../src/types/WorkerExports.ts | 2 + yarn.lock | 200 ++++- 80 files changed, 4545 insertions(+), 90 deletions(-) create mode 100644 packages/twenty-front-component-renderer/src/__stories__/Geometry.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/showcase/geometry-measure.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/showcase/recharts-example.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__tests__/createElementGeometrySnapshotFixture.ts create mode 100644 packages/twenty-front-component-renderer/src/__tests__/createStubGeometryTracker.ts create mode 100644 packages/twenty-front-component-renderer/src/__tests__/createViewportGeometrySnapshotFixture.ts create mode 100644 packages/twenty-front-component-renderer/src/__tests__/createWorkerGeometryStoreStub.ts create mode 100644 packages/twenty-front-component-renderer/src/constants/MaxObservedGeometryElements.ts create mode 100644 packages/twenty-front-component-renderer/src/host/constants/GeometryEpsilonPixels.ts create mode 100644 packages/twenty-front-component-renderer/src/host/constants/GeometryIdleFrameThreshold.ts create mode 100644 packages/twenty-front-component-renderer/src/host/constants/GeometryUnregisteredObservationExpiryFrames.ts create mode 100644 packages/twenty-front-component-renderer/src/host/constants/RootContainerStyle.ts create mode 100644 packages/twenty-front-component-renderer/src/host/contexts/FrontComponentGeometryTrackerContext.ts create mode 100644 packages/twenty-front-component-renderer/src/host/hooks/__tests__/useGeometryNodeRef.test.tsx create mode 100644 packages/twenty-front-component-renderer/src/host/hooks/useGeometryNodeRef.ts create mode 100644 packages/twenty-front-component-renderer/src/host/types/GeometryTracker.ts create mode 100644 packages/twenty-front-component-renderer/src/host/types/GeometryWakeSources.ts create mode 100644 packages/twenty-front-component-renderer/src/host/types/PushGeometryUpdates.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/createGeometryTracker.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapperGeometryRegistration.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/getRemoteElementIdFromProps.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/isGeometrySnapshotEqualWithinEpsilon.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/measureNodeGeometry.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/measureViewportGeometry.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/sanitizeRemoteElementIds.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/setupGeometryGlobals.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/createGeometryTracker.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/createGeometryWakeSources.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/getRemoteElementIdFromProps.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/isGeometrySnapshotEqualWithinEpsilon.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/measureNodeGeometry.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/measureViewportGeometry.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/sanitizeRemoteElementIds.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementLike.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/types/ElementWithStyle.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installDocumentGetElementById.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetComputedStyle.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installGetElementsByClassName.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/installLocalStyleOnBaseElements.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/installDocumentGetElementById.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetComputedStyle.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/installGetElementsByClassName.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/installLocalStyleOnBaseElements.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/iterateElementSubtree.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryObservationLimitWarning.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/constants/GeometryTransportFailureWarning.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/types/GeometryObservationTransport.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/types/WorkerGeometryStore.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createDomRectFromSnapshot.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/createWorkerGeometryStore.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installElementGeometryPolyfill.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/installWindowGeometryPolyfill.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/__tests__/isElementUnderRemoteRoot.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createDomRectFromSnapshot.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/createWorkerGeometryStore.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installElementGeometryPolyfill.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/installWindowGeometryPolyfill.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/utils/isElementUnderRemoteRoot.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/geometry/workerGeometryStore.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/utils/resolveGlobalScopeInstallTargets.ts create mode 100644 packages/twenty-front-component-renderer/src/remote/components/FrontComponentGeometryTrackerEffect.tsx create mode 100644 packages/twenty-front-component-renderer/src/remote/components/__tests__/FrontComponentGeometryTrackerEffect.test.ts create mode 100644 packages/twenty-front-component-renderer/src/types/ElementGeometrySnapshot.ts create mode 100644 packages/twenty-front-component-renderer/src/types/GeometryUpdateBatch.ts create mode 100644 packages/twenty-front-component-renderer/src/types/ViewportGeometrySnapshot.ts 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"