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<br/>recharts, twenty-ui"] -->|"el.getBoundingClientRect()"| DOM["remote-dom fake DOM<br/>in the Web Worker"]
    DOM --> MISS["No layout APIs:<br/>method does not exist"]
    MISS --> BOOM["TypeError, component crashes"]
    HOST["Host: real DOM nodes<br/>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<br/>resize, scroll, mutations, animation events"]
        TRACK["createGeometryTracker<br/>rAF loop, idles after 20 unchanged frames"]
        NODES["Real DOM nodes<br/>registered per remote element id"]
    end

    subgraph WORKER["Web Worker - fake DOM"]
        STORE["workerGeometryStore<br/>snapshot mirror"]
        POLY["Element.prototype polyfill<br/>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:<br/>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 `<div style="width:100%;height:100%">` 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).
This commit is contained in:
Raphaël Bosi
2026-07-30 14:02:18 +02:00
committed by GitHub
parent cdeebb1a18
commit 014b3cdc67
80 changed files with 4545 additions and 90 deletions

View File

@@ -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",

View File

@@ -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<typeof FrontComponentRenderer> = {
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<typeof FrontComponentRenderer>;
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();
},
);

View File

@@ -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',
);

View File

@@ -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<HTMLDivElement>(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 (
<div
data-testid="geometry-measure-component"
style={{ fontFamily: 'system-ui, sans-serif', padding: 16 }}
>
<div
ref={measuredBoxRef}
data-testid="geometry-measure-box"
style={{
width: MEASURED_BOX_WIDTH,
height: MEASURED_BOX_HEIGHT,
backgroundColor: '#dbeafe',
borderRadius: 8,
}}
/>
<p data-testid="geometry-measure-width">
width: {measuredGeometry.width}
</p>
<p data-testid="geometry-measure-height">
height: {measuredGeometry.height}
</p>
<p data-testid="geometry-measure-offset-width">
offsetWidth: {measuredGeometry.offsetWidth}
</p>
<p data-testid="geometry-measure-inner-width">
innerWidth: {measuredGeometry.innerWidth}
</p>
</div>
);
};
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,
});

View File

@@ -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 = () => (
<div
data-testid="recharts-component"
style={{ fontFamily: 'system-ui, sans-serif', padding: 16 }}
>
<AreaChart width={480} height={280} data={CHART_DATA}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Area
type="monotone"
dataKey="revenue"
stroke="#2563eb"
fill="#bfdbfe"
isAnimationActive={false}
/>
</AreaChart>
</div>
);
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,
});

View File

@@ -0,0 +1,23 @@
import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot';
export const createElementGeometrySnapshotFixture = (
overrides: Partial<ElementGeometrySnapshot> = {},
): 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,
});

View File

@@ -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<ViewportGeometrySnapshot, []>(),
reset: jest.fn(),
});

View File

@@ -0,0 +1,18 @@
import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot';
export const createViewportGeometrySnapshotFixture = (
overrides: Partial<ViewportGeometrySnapshot> = {},
): ViewportGeometrySnapshot => ({
innerWidth: 0,
innerHeight: 0,
devicePixelRatio: 1,
scrollX: 0,
scrollY: 0,
rootContainerX: 0,
rootContainerY: 0,
rootContainerWidth: 0,
rootContainerHeight: 0,
rootContainerClientWidth: 0,
rootContainerClientHeight: 0,
...overrides,
});

View File

@@ -0,0 +1,12 @@
import { type WorkerGeometryStore } from '@/polyfills/geometry/types/WorkerGeometryStore';
export const createWorkerGeometryStoreStub = (
overrides: Partial<WorkerGeometryStore> = {},
): WorkerGeometryStore => ({
setRootElement: jest.fn(),
connectTransport: jest.fn(),
applyGeometryBatch: jest.fn(),
getViewportSnapshot: jest.fn(() => null),
resolveElementSnapshot: jest.fn(() => null),
...overrides,
});

View File

@@ -0,0 +1 @@
export const MAX_OBSERVED_GEOMETRY_ELEMENTS = 500;

View File

@@ -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<Error | null>(null);
const [isExecutionContextInitialized, setIsExecutionContextInitialized] =
useState(false);
const [geometryTracker] = useState(() => createGeometryTracker());
const isReady = isDefined(receiver) && isExecutionContextInitialized;
return (
<>
<FrontComponentWorkerEffect
componentUrl={componentUrl}
applicationAccessToken={applicationAccessToken}
apiUrl={apiUrl}
functionsBaseUrl={functionsBaseUrl}
sdkClientUrls={sdkClientUrls}
applicationVariables={applicationVariables}
setReceiver={setReceiver}
setThread={setThread}
setError={setError}
/>
<FrontComponentGeometryTrackerContext.Provider value={geometryTracker}>
<div ref={geometryTracker.setRoot} style={ROOT_CONTAINER_STYLE}>
<FrontComponentWorkerEffect
componentUrl={componentUrl}
applicationAccessToken={applicationAccessToken}
apiUrl={apiUrl}
functionsBaseUrl={functionsBaseUrl}
sdkClientUrls={sdkClientUrls}
applicationVariables={applicationVariables}
geometryTracker={geometryTracker}
setReceiver={setReceiver}
setThread={setThread}
setError={setError}
/>
{isDefined(error) && (
<ThemeProvider colorScheme={colorScheme}>
<FrontComponentErrorEffect error={error} onError={onError} />
<FrontComponentErrorBox error={error} />
</ThemeProvider>
)}
{isDefined(error) && (
<ThemeProvider colorScheme={colorScheme}>
<FrontComponentErrorEffect error={error} onError={onError} />
<FrontComponentErrorBox error={error} />
</ThemeProvider>
)}
{isDefined(thread) && (
<>
<FrontComponentUpdateHostCommunicationApiEffect
thread={thread}
frontComponentHostCommunicationApi={
frontComponentHostCommunicationApi
}
/>
<FrontComponentInitializeHostCommunicationApiEffect thread={thread} />
<FrontComponentUpdateContextEffect
thread={thread}
executionContext={executionContext}
onExecutionContextInitialized={() =>
setIsExecutionContextInitialized(true)
}
/>
<FrontComponentConfirmationModalResultEffect
thread={thread}
frontComponentId={executionContext.frontComponentId}
onError={setError}
/>
</>
)}
{isDefined(thread) && (
<>
<FrontComponentUpdateHostCommunicationApiEffect
thread={thread}
frontComponentHostCommunicationApi={
frontComponentHostCommunicationApi
}
/>
<FrontComponentInitializeHostCommunicationApiEffect
thread={thread}
/>
<FrontComponentGeometryTrackerEffect
thread={thread}
geometryTracker={geometryTracker}
/>
<FrontComponentUpdateContextEffect
thread={thread}
executionContext={executionContext}
onExecutionContextInitialized={() =>
setIsExecutionContextInitialized(true)
}
/>
<FrontComponentConfirmationModalResultEffect
thread={thread}
frontComponentId={executionContext.frontComponentId}
onError={setError}
/>
</>
)}
{!isDefined(error) && !isReady && loadingFallback}
{!isDefined(error) && !isReady && loadingFallback}
{isReady && (
<ThemeProvider colorScheme={colorScheme}>
<ErrorBoundary
onError={setError}
onReset={() => setError(null)}
resetKeys={[componentUrl]}
fallbackRender={() => null}
>
<FrontComponentExternalNavigationContext.Provider
value={onRequestExternalNavigation ?? null}
{isReady && (
<ThemeProvider colorScheme={colorScheme}>
<ErrorBoundary
onError={setError}
onReset={() => setError(null)}
resetKeys={[componentUrl]}
fallbackRender={() => null}
>
<RemoteRootRenderer
receiver={receiver}
components={fallbackComponentRegistry}
/>
</FrontComponentExternalNavigationContext.Provider>
</ErrorBoundary>
</ThemeProvider>
)}
</>
<FrontComponentExternalNavigationContext.Provider
value={onRequestExternalNavigation ?? null}
>
<RemoteRootRenderer
receiver={receiver}
components={fallbackComponentRegistry}
/>
</FrontComponentExternalNavigationContext.Provider>
</ErrorBoundary>
</ThemeProvider>
)}
</div>
</FrontComponentGeometryTrackerContext.Provider>
);
};

View File

@@ -0,0 +1 @@
export const GEOMETRY_EPSILON_PIXELS = 0.05;

View File

@@ -0,0 +1 @@
export const GEOMETRY_IDLE_FRAME_THRESHOLD = 20;

View File

@@ -0,0 +1 @@
export const GEOMETRY_UNREGISTERED_OBSERVATION_EXPIRY_FRAMES = 20;

View File

@@ -0,0 +1,6 @@
import { type CSSProperties } from 'react';
export const ROOT_CONTAINER_STYLE: CSSProperties = {
width: '100%',
height: '100%',
};

View File

@@ -0,0 +1,6 @@
import { createContext } from 'react';
import { type GeometryTracker } from '@/host/types/GeometryTracker';
export const FrontComponentGeometryTrackerContext =
createContext<GeometryTracker | null>(null);

View File

@@ -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),
);
});
});

View File

@@ -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],
);
};

View File

@@ -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 =

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -0,0 +1,3 @@
import { type GeometryUpdateBatch } from '@/types/GeometryUpdateBatch';
export type PushGeometryUpdates = (batch: GeometryUpdateBatch) => void;

View File

@@ -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<typeof createGeometryTracker>[] = [];
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();
});
});

View File

@@ -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<never>,
props: Record<string, unknown>,
) => 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('<div></div>');
});
});
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');

View File

@@ -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<never>,
props: Record<string, unknown>,
) => 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<string, unknown>,
) => {
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();
});
});

View File

@@ -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,
);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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']);
});
});

View File

@@ -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;
},
};
},
};
};

View File

@@ -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(),
});

View File

@@ -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<string, Element>();
const observedRemoteElementIds = new Set<string>();
const lastElementSnapshots = new Map<string, ElementGeometrySnapshot>();
const unregisteredObservedFrameCounts = new Map<string, number>();
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<string, ElementGeometrySnapshot> = {};
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,
};
};

View File

@@ -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<Element>();
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,
};
};

View File

@@ -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, unknown>,
): string | undefined => {
const remoteElement = (props as RemoteElementPropCarrier)[
REMOTE_ELEMENT_PROP
];
const remoteElementId = remoteElement?.id;
return isString(remoteElementId) ? remoteElementId : undefined;
};

View File

@@ -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<string, number | string | null>,
>(
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;
});
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -0,0 +1,11 @@
import { isArray, isNonEmptyString } from '@sniptt/guards';
export const sanitizeRemoteElementIds = (
remoteElementIds: unknown,
): string[] => {
if (!isArray(remoteElementIds)) {
return [];
}
return remoteElementIds.filter(isNonEmptyString);
};

View File

@@ -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';

View File

@@ -0,0 +1,4 @@
export type ElementLike = {
childNodes?: ArrayLike<unknown>;
getAttribute?: (attributeName: string) => string | null;
};

View File

@@ -0,0 +1,3 @@
export type ElementWithStyle = {
style?: unknown;
};

View File

@@ -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);
});
});

View File

@@ -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<string, unknown> = {};
const globalScope: Record<string, unknown> = { 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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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('');
});
});

View File

@@ -0,0 +1,138 @@
import { installGetElementsByClassName } from '../installGetElementsByClassName';
class FakeElement {
childNodes: FakeElement[] = [];
className?: string;
private attributes = new Map<string, string>();
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();
});
});

View File

@@ -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);
});
});

View File

@@ -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;
};
};

View File

@@ -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<string, unknown>,
): 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;
}
};

View File

@@ -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,
});
};

View File

@@ -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<object, Record<string, unknown>>();
const resolveLocalStyleDeclaration = (
element: object,
): Record<string, unknown> => {
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,
});
};

View File

@@ -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<ElementLike> {
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);
}
}
}
}
}

View File

@@ -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.`;

View File

@@ -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.';

View File

@@ -0,0 +1,6 @@
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
export type GeometryObservationTransport = Pick<
FrontComponentHostThreadExports,
'observeElementGeometry' | 'unobserveElementGeometry'
>;

View File

@@ -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;
};

View File

@@ -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;
}
});
});

View File

@@ -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<object, string>();
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);
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, unknown> = {};
const globalScope: Record<string, unknown> = { 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<string, unknown> = {};
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<string, unknown> = {};
installWindowGeometryPolyfill({
globalScope,
geometryStore: createWorkerGeometryStoreStub({
getViewportSnapshot: () => viewport,
}),
});
expect(globalScope.innerWidth).toBe(0);
viewport = createViewport();
expect(globalScope.innerWidth).toBe(1024);
});
});

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -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<string, ElementGeometrySnapshot>();
const enrolledRemoteElementIds = new WeakMap<object, string>();
const observedRemoteElementIds = new Set<string>();
const pendingObservationIds = new Set<string>();
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,
};
};

View File

@@ -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,
});
};

View File

@@ -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<string, unknown>;
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,
});
}
}
};

View File

@@ -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;
};

View File

@@ -0,0 +1,3 @@
import { createWorkerGeometryStore } from '@/polyfills/geometry/utils/createWorkerGeometryStore';
export const workerGeometryStore = createWorkerGeometryStore();

View File

@@ -0,0 +1,13 @@
import { isObject } from '@sniptt/guards';
export const resolveGlobalScopeInstallTargets = (
globalScope: Record<string, unknown>,
): Record<string, unknown>[] => {
const polyfillWindow = globalScope.window;
if (isObject(polyfillWindow) && polyfillWindow !== globalScope) {
return [globalScope, polyfillWindow as Record<string, unknown>];
}
return [globalScope];
};

View File

@@ -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;
};

View File

@@ -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<string, string>;
geometryTracker: GeometryTracker;
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
setThread: React.Dispatch<React.SetStateAction<FrontComponentThread | null>>;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
@@ -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,

View File

@@ -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);
});
});

View File

@@ -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<string, unknown>);
installElementGeometryPolyfill({
elementPrototype: Element.prototype,
documentTarget: document,
geometryStore: workerGeometryStore,
});
installWindowGeometryPolyfill({
globalScope: globalThis as unknown as Record<string, unknown>,
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();
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -4,4 +4,6 @@ import { type HostFetchFunction } from '@/types/HostFetchFunction';
export type FrontComponentHostThreadExports =
FrontComponentHostCommunicationApi & {
hostFetch: HostFetchFunction;
observeElementGeometry: (remoteElementIds: string[]) => Promise<void>;
unobserveElementGeometry: (remoteElementIds: string[]) => Promise<void>;
};

View File

@@ -0,0 +1,8 @@
import { type ElementGeometrySnapshot } from '@/types/ElementGeometrySnapshot';
import { type ViewportGeometrySnapshot } from '@/types/ViewportGeometrySnapshot';
export type GeometryUpdateBatch = {
viewport?: ViewportGeometrySnapshot;
elements?: Record<string, ElementGeometrySnapshot>;
removedRemoteElementIds?: string[];
};

View File

@@ -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<string, string>;
initialViewportGeometry?: ViewportGeometrySnapshot;
};

View File

@@ -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;
};

View File

@@ -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<void>;
pushGeometryUpdates: (batch: GeometryUpdateBatch) => Promise<void>;
};

200
yarn.lock
View File

@@ -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"