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:
@@ -0,0 +1,4 @@
|
||||
export type ElementLike = {
|
||||
childNodes?: ArrayLike<unknown>;
|
||||
getAttribute?: (attributeName: string) => string | null;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ElementWithStyle = {
|
||||
style?: unknown;
|
||||
};
|
||||
+80
@@ -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);
|
||||
});
|
||||
});
|
||||
+62
@@ -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('');
|
||||
});
|
||||
});
|
||||
+138
@@ -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();
|
||||
});
|
||||
});
|
||||
+78
@@ -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);
|
||||
});
|
||||
});
|
||||
+33
@@ -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;
|
||||
};
|
||||
};
|
||||
+30
@@ -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;
|
||||
}
|
||||
};
|
||||
+65
@@ -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,
|
||||
});
|
||||
};
|
||||
+37
@@ -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,
|
||||
});
|
||||
};
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user