mirror of
https://github.com/twentyhq/twenty.git
synced 2026-08-01 10:12:06 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_OBSERVED_GEOMETRY_ELEMENTS = 500;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const GEOMETRY_EPSILON_PIXELS = 0.05;
|
||||
@@ -0,0 +1 @@
|
||||
export const GEOMETRY_IDLE_FRAME_THRESHOLD = 20;
|
||||
@@ -0,0 +1 @@
|
||||
export const GEOMETRY_UNREGISTERED_OBSERVATION_EXPIRY_FRAMES = 20;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type CSSProperties } from 'react';
|
||||
|
||||
export const ROOT_CONTAINER_STYLE: CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import { type GeometryTracker } from '@/host/types/GeometryTracker';
|
||||
|
||||
export const FrontComponentGeometryTrackerContext =
|
||||
createContext<GeometryTracker | null>(null);
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type GeometryUpdateBatch } from '@/types/GeometryUpdateBatch';
|
||||
|
||||
export type PushGeometryUpdates = (batch: GeometryUpdateBatch) => void;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isArray, isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const sanitizeRemoteElementIds = (
|
||||
remoteElementIds: unknown,
|
||||
): string[] => {
|
||||
if (!isArray(remoteElementIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return remoteElementIds.filter(isNonEmptyString);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ElementLike = {
|
||||
childNodes?: ArrayLike<unknown>;
|
||||
getAttribute?: (attributeName: string) => string | null;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ElementWithStyle = {
|
||||
style?: unknown;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.`;
|
||||
@@ -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.';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports';
|
||||
|
||||
export type GeometryObservationTransport = Pick<
|
||||
FrontComponentHostThreadExports,
|
||||
'observeElementGeometry' | 'unobserveElementGeometry'
|
||||
>;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createWorkerGeometryStore } from '@/polyfills/geometry/utils/createWorkerGeometryStore';
|
||||
|
||||
export const workerGeometryStore = createWorkerGeometryStore();
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
200
yarn.lock
@@ -15514,6 +15514,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@reduxjs/toolkit@npm:^1.9.0 || 2.x.x":
|
||||
version: 2.12.0
|
||||
resolution: "@reduxjs/toolkit@npm:2.12.0"
|
||||
dependencies:
|
||||
"@standard-schema/spec": "npm:^1.0.0"
|
||||
"@standard-schema/utils": "npm:^0.3.0"
|
||||
immer: "npm:^11.0.0"
|
||||
redux: "npm:^5.0.1"
|
||||
redux-thunk: "npm:^3.1.0"
|
||||
reselect: "npm:^5.1.0"
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
checksum: 10c0/2b85e31294a7139994fd78b08eb3ce706ce3b03b5081c13403b93ba4883a152d637171e4d9d969766238851f8915b1daa9f36b5a0a458aff0ff7600ee38795c9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@remirror/core-constants@npm:3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@remirror/core-constants@npm:3.0.0"
|
||||
@@ -18180,7 +18202,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@standard-schema/spec@npm:^1.1.0":
|
||||
"@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "@standard-schema/spec@npm:1.1.0"
|
||||
checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
|
||||
@@ -19880,6 +19902,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-array@npm:^3.0.3":
|
||||
version: 3.2.2
|
||||
resolution: "@types/d3-array@npm:3.2.2"
|
||||
checksum: 10c0/6137cb97302f8a4f18ca22c0560c585cfcb823f276b23d89f2c0c005d72697ec13bca671c08e68b4b0cabd622e3f0e91782ee221580d6774074050be96dd7028
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-color@npm:*, @types/d3-color@npm:^3.0.0":
|
||||
version: 3.1.3
|
||||
resolution: "@types/d3-color@npm:3.1.3"
|
||||
@@ -19903,6 +19932,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-ease@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "@types/d3-ease@npm:3.0.2"
|
||||
checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-format@npm:^1.4.1":
|
||||
version: 1.4.5
|
||||
resolution: "@types/d3-format@npm:1.4.5"
|
||||
@@ -19910,7 +19946,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.4":
|
||||
"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.1, @types/d3-interpolate@npm:^3.0.4":
|
||||
version: 3.0.4
|
||||
resolution: "@types/d3-interpolate@npm:3.0.4"
|
||||
dependencies:
|
||||
@@ -19933,6 +19969,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-scale@npm:^4.0.2":
|
||||
version: 4.0.9
|
||||
resolution: "@types/d3-scale@npm:4.0.9"
|
||||
dependencies:
|
||||
"@types/d3-time": "npm:*"
|
||||
checksum: 10c0/4ac44233c05cd50b65b33ecb35d99fdf07566bcdbc55bc1306b2f27d1c5134d8c560d356f2c8e76b096e9125ffb8d26d95f78d56e210d1c542cb255bdf31d6c8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-scale@npm:^4.0.8":
|
||||
version: 4.0.8
|
||||
resolution: "@types/d3-scale@npm:4.0.8"
|
||||
@@ -19949,6 +19994,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-shape@npm:^3.1.0":
|
||||
version: 3.1.8
|
||||
resolution: "@types/d3-shape@npm:3.1.8"
|
||||
dependencies:
|
||||
"@types/d3-path": "npm:*"
|
||||
checksum: 10c0/49ec2172b9eb66fc1d036e2a23966216bb972e9af51ddbed134df24bd71aedf207bb1ef81903a119eb4e1f5e927cf44beacaf64c9af86474e5548594b102b574
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-shape@npm:^3.1.6":
|
||||
version: 3.1.6
|
||||
resolution: "@types/d3-shape@npm:3.1.6"
|
||||
@@ -19986,6 +20040,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-time@npm:^3.0.0":
|
||||
version: 3.0.4
|
||||
resolution: "@types/d3-time@npm:3.0.4"
|
||||
checksum: 10c0/6d9e2255d63f7a313a543113920c612e957d70da4fb0890931da6c2459010291b8b1f95e149a538500c1c99e7e6c89ffcce5554dd29a31ff134a38ea94b6d174
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-timer@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "@types/d3-timer@npm:3.0.2"
|
||||
checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-transition@npm:^3.0.8":
|
||||
version: 3.0.8
|
||||
resolution: "@types/d3-transition@npm:3.0.8"
|
||||
@@ -27780,7 +27848,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3":
|
||||
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
|
||||
version: 3.2.4
|
||||
resolution: "d3-array@npm:3.2.4"
|
||||
dependencies:
|
||||
@@ -27822,7 +27890,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-ease@npm:1 - 3":
|
||||
"d3-ease@npm:1 - 3, d3-ease@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-ease@npm:3.0.1"
|
||||
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
|
||||
@@ -27889,7 +27957,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-shape@npm:^3.2.0":
|
||||
"d3-shape@npm:^3.1.0, d3-shape@npm:^3.2.0":
|
||||
version: 3.2.0
|
||||
resolution: "d3-shape@npm:3.2.0"
|
||||
dependencies:
|
||||
@@ -27925,7 +27993,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3":
|
||||
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0":
|
||||
version: 3.1.0
|
||||
resolution: "d3-time@npm:3.1.0"
|
||||
dependencies:
|
||||
@@ -27941,7 +28009,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-timer@npm:1 - 3":
|
||||
"d3-timer@npm:1 - 3, d3-timer@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-timer@npm:3.0.1"
|
||||
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
|
||||
@@ -28314,6 +28382,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js-light@npm:^2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "decimal.js-light@npm:2.5.1"
|
||||
checksum: 10c0/4fd33f535aac9e5bd832796831b65d9ec7914ad129c7437b3ab991b0c2eaaa5a57e654e6174c4a17f1b3895ea366f0c1ab4955cdcdf7cfdcf3ad5a58b456c020
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js@npm:^10.4.3, decimal.js@npm:^10.5.0":
|
||||
version: 10.5.0
|
||||
resolution: "decimal.js@npm:10.5.0"
|
||||
@@ -29610,6 +29685,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-toolkit@npm:^1.39.3":
|
||||
version: 1.50.0
|
||||
resolution: "es-toolkit@npm:1.50.0"
|
||||
dependenciesMeta:
|
||||
"@trivago/prettier-plugin-sort-imports@4.3.0":
|
||||
unplugged: true
|
||||
prettier-plugin-sort-re-exports@0.0.1:
|
||||
unplugged: true
|
||||
vitepress-plugin-sandpack@1.1.4:
|
||||
unplugged: true
|
||||
checksum: 10c0/80e3a212d337df6f20ed0330d1b62dda25f3ef6d4c83a33f13d521ccbf5b4ad1ac3e7e638572abd6080627f522707de76a5c9360350b713e5392739083ee1f24
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"esast-util-from-estree@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "esast-util-from-estree@npm:2.0.0"
|
||||
@@ -33498,6 +33587,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"immer@npm:^11.0.0, immer@npm:^11.1.8":
|
||||
version: 11.1.15
|
||||
resolution: "immer@npm:11.1.15"
|
||||
checksum: 10c0/5215eaec9641ad786ae8940a489514c26ce3726f8c49f0191a754902800f3ddd102b0653e894cabe7049d93e77b86d67d19953e778ffebba56848739a31b3b79
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"immer@npm:^9.0.6":
|
||||
version: 9.0.21
|
||||
resolution: "immer@npm:9.0.21"
|
||||
@@ -43765,6 +43861,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:8.x.x || 9.x.x":
|
||||
version: 9.3.0
|
||||
resolution: "react-redux@npm:9.3.0"
|
||||
dependencies:
|
||||
"@types/use-sync-external-store": "npm:^0.0.6"
|
||||
use-sync-external-store: "npm:^1.4.0"
|
||||
peerDependencies:
|
||||
"@types/react": ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
checksum: 10c0/b9f4efcfbfbc90cac9d1709ab3affb1e18a9dc9bd3cceda43bd2e1d9d2394ee0c29df36ec2202d52b566db774888b594c8c5aa86b64f27ef34fca607c687c9e3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-remove-scroll-bar@npm:^2.3.7":
|
||||
version: 2.3.8
|
||||
resolution: "react-remove-scroll-bar@npm:2.3.8"
|
||||
@@ -44094,6 +44209,29 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recharts@npm:^3.9.2":
|
||||
version: 3.10.0
|
||||
resolution: "recharts@npm:3.10.0"
|
||||
dependencies:
|
||||
"@reduxjs/toolkit": "npm:^1.9.0 || 2.x.x"
|
||||
clsx: "npm:^2.1.1"
|
||||
decimal.js-light: "npm:^2.5.1"
|
||||
es-toolkit: "npm:^1.39.3"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
immer: "npm:^11.1.8"
|
||||
react-redux: "npm:8.x.x || 9.x.x"
|
||||
reselect: "npm:5.2.0"
|
||||
tiny-invariant: "npm:^1.3.3"
|
||||
use-sync-external-store: "npm:^1.2.2"
|
||||
victory-vendor: "npm:^37.0.2"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/223524f5db2bb660878c37d70a441b899e9db861dd47768ccccd3982b43d0233cbc3b4a01d32abb76d8782320839cf4a3dc3de9bc002f7c9177b656484efe506
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recma-build-jsx@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "recma-build-jsx@npm:1.0.0"
|
||||
@@ -44184,6 +44322,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux-thunk@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "redux-thunk@npm:3.1.0"
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
checksum: 10c0/21557f6a30e1b2e3e470933247e51749be7f1d5a9620069a3125778675ce4d178d84bdee3e2a0903427a5c429e3aeec6d4df57897faf93eb83455bc1ef7b66fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "redux@npm:5.0.1"
|
||||
checksum: 10c0/b10c28357194f38e7d53b760ed5e64faa317cc63de1fb95bc5d9e127fab956392344368c357b8e7a9bedb0c35b111e7efa522210cfdc3b3c75e5074718e9069c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reflect-metadata@npm:0.2.2, reflect-metadata@npm:^0.2.2":
|
||||
version: 0.2.2
|
||||
resolution: "reflect-metadata@npm:0.2.2"
|
||||
@@ -44671,6 +44825,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reselect@npm:5.2.0, reselect@npm:^5.1.0":
|
||||
version: 5.2.0
|
||||
resolution: "reselect@npm:5.2.0"
|
||||
checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reselect@npm:^4.1.7":
|
||||
version: 4.1.8
|
||||
resolution: "reselect@npm:4.1.8"
|
||||
@@ -48940,6 +49101,7 @@ __metadata:
|
||||
react: "npm:^19.2.0"
|
||||
react-dom: "npm:^19.2.0"
|
||||
react-error-boundary: "npm:^4.0.11"
|
||||
recharts: "npm:^3.9.2"
|
||||
storybook: "npm:^10.4.6"
|
||||
styled-components: "npm:^6.1.0"
|
||||
ts-morph: "npm:^25.0.0"
|
||||
@@ -50784,7 +50946,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-sync-external-store@npm:1.6.0, use-sync-external-store@npm:^1.5.0, use-sync-external-store@npm:^1.6.0":
|
||||
"use-sync-external-store@npm:1.6.0, use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.5.0, use-sync-external-store@npm:^1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "use-sync-external-store@npm:1.6.0"
|
||||
peerDependencies:
|
||||
@@ -51038,6 +51200,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"victory-vendor@npm:^37.0.2":
|
||||
version: 37.3.6
|
||||
resolution: "victory-vendor@npm:37.3.6"
|
||||
dependencies:
|
||||
"@types/d3-array": "npm:^3.0.3"
|
||||
"@types/d3-ease": "npm:^3.0.0"
|
||||
"@types/d3-interpolate": "npm:^3.0.1"
|
||||
"@types/d3-scale": "npm:^4.0.2"
|
||||
"@types/d3-shape": "npm:^3.1.0"
|
||||
"@types/d3-time": "npm:^3.0.0"
|
||||
"@types/d3-timer": "npm:^3.0.0"
|
||||
d3-array: "npm:^3.1.6"
|
||||
d3-ease: "npm:^3.0.1"
|
||||
d3-interpolate: "npm:^3.0.1"
|
||||
d3-scale: "npm:^4.0.2"
|
||||
d3-shape: "npm:^3.1.0"
|
||||
d3-time: "npm:^3.0.0"
|
||||
d3-timer: "npm:^3.0.1"
|
||||
checksum: 10c0/0a9628db30b898160409628f26d457ba15adc86472531817d73fbeb847de498d94f91dee5f4caa969195744e91be93e100eeff7a392b591ac5349343a36dec29
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vinyl-file@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "vinyl-file@npm:5.0.0"
|
||||
|
||||
Reference in New Issue
Block a user