Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/batched-style-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@cube-dev/ui-kit': minor
---

Collapse the kit's stylesheet writes into one style invalidation per commit.

Every `insertRule()` on a live stylesheet invalidates style for that sheet's
scope. Kit components inject during React's render phase, so when anything else
reads layout in the same pass — a tooltip positioning itself, `TextArea`
autosizing, a virtualized table measuring rows — the two interleave and the
browser is forced to recalculate style between every injection.

`<Root>` now enables tasty's `batchInjection` and opens a batch window for its
own commits, and `<Portal>` opens one for every overlay that mounts, which is
where injection and measurement interleave worst. Writes are queued and applied
together, and the flush happens in `useInsertionEffect` — before any
`useLayoutEffect` — so nothing can measure an element whose rules have not landed
yet. Any commit without a window in it writes straight through exactly as before.

No API change: no new props, no new setup. SSR is unaffected — styles are
collected as text there and the provider is inert without a `document`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-virtual": "^3.13.12",
"@tenphi/glaze": "2.0.0",
"@tenphi/tasty": "^3.1.0",
"@tenphi/tasty": "0.0.0-snapshot.a500c0a",
"clipboard-copy": "^4.0.1",
"clsx": "^1.1.1",
"diff": "^8.0.3",
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

149 changes: 149 additions & 0 deletions src/components/Root.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { hasPendingStyleWrites, tasty } from '@tenphi/tasty';
import { act, render } from '@testing-library/react';
import { useLayoutEffect, useRef, useState } from 'react';

import { Portal } from './portal';
import { Root } from './Root';

/**
* The kit's batched-injection wiring.
*
* `configure({ batchInjection: true })` in `Root` only does something inside a
* batch window, so the wiring can silently become a no-op — the flag stays on,
* the provider goes missing, and every render is quietly back to one
* `insertRule()` per component. These tests assert on the queue itself so that
* cannot happen unnoticed.
*
* They also assert on `getBoundingClientRect()` inside a `useLayoutEffect`,
* which is the property that makes batching safe to enable at all: a queued
* write must land before anything can measure. Asserting on CSS text would not
* catch a regression there.
*/

const WIDTH = 317;

/** A component that measures itself in a layout effect, like a popover does. */
function makeMeasured(record: (width: number) => void) {
const Box = tasty({ styles: { width: `${WIDTH}px`, height: '10px' } });

return function Measured() {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
record(ref.current!.getBoundingClientRect().width);
}, []);
return <Box ref={ref} />;
};
}

describe('Root batched injection', () => {
it('batches during the mount commit', () => {
const Box = tasty({ styles: { letterSpacing: '0.013em' } });
let pendingMidRender: boolean | null = null;

// Renders after <Box/>, so anything Box queued is still queued here.
function Probe() {
pendingMidRender = hasPendingStyleWrites();
return null;
}

render(
<Root>
<Box />
<Probe />
</Root>,
);

expect(pendingMidRender).toBe(true);
// Root's insertion effect drained the queue before the commit finished.
expect(hasPendingStyleWrites()).toBe(false);
});

it('has the rules in the sheet before layout effects run', () => {
let measured = -1;
const Measured = makeMeasured((w) => {
measured = w;
});

render(
<Root>
<Measured />
</Root>,
);

expect(measured).toBe(WIDTH);
});

// Root does not re-render when an overlay opens, so its window does not cover
// that commit. Portal opens one of its own — this is what makes dialogs,
// tooltips and menus benefit rather than just the initial mount.
it('batches a portal that mounts without re-rendering Root', () => {
const Box = tasty({ styles: { letterSpacing: '0.029em' } });
// One observation per render. `Portal` renders its children inline first and
// again once `mountRoot` resolves, and the second pass is a cache hit with
// nothing left to queue — so the question is whether *a* render batched, not
// what the last one saw.
const observed: boolean[] = [];
let open: (value: boolean) => void = () => {};

function Probe() {
observed.push(hasPendingStyleWrites());
return null;
}

function Host() {
const [isOpen, setOpen] = useState(false);
open = setOpen;

if (!isOpen) return null;

return (
<Portal>
<Box />
<Probe />
</Portal>
);
}

render(
<Root>
<Host />
</Root>,
);

expect(observed).toEqual([]);

act(() => open(true));

expect(observed).toContain(true);
expect(hasPendingStyleWrites()).toBe(false);
});

it('measures correctly inside a portal that mounts later', () => {
let measured = -1;
let open: (value: boolean) => void = () => {};
const Measured = makeMeasured((w) => {
measured = w;
});

function Host() {
const [isOpen, setOpen] = useState(false);
open = setOpen;

return isOpen ? (
<Portal>
<Measured />
</Portal>
) : null;
}

render(
<Root>
<Host />
</Root>,
);

act(() => open(true));

expect(measured).toBe(WIDTH);
});
});
86 changes: 49 additions & 37 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
filterBaseProps,
setGlobalPredefinedStates,
tasty,
TastyBatchProvider,
} from '@tenphi/tasty';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ModalProvider } from 'react-aria';
Expand Down Expand Up @@ -40,6 +41,13 @@ setGlobalPredefinedStates({

configure({
colorSpace: 'rgb',
// Collapse the kit's stylesheet writes into one style invalidation per commit
// instead of one per component. Only takes effect inside a
// `<TastyBatchProvider>` window — `<Root>` opens one below, and `<Portal>`
// opens one for every overlay that mounts — so a `useLayoutEffect` can never
// measure an element whose rules have not landed yet. Without a provider in
// the commit, writes go straight through exactly as before.
batchInjection: true,
units: {
x: 'var(--gap)',
r: 'var(--radius)',
Expand Down Expand Up @@ -203,42 +211,46 @@ export function Root(allProps: CubeRootProps) {
const styles = extractStyles(props, STYLES);

return (
<I18nProvider i18n={i18n} locale={locale}>
<Provider navigation={navigation} root={rootRef}>
<TrackingProvider event={tracking?.event}>
<RootElement
ref={ref}
data-uikit={VERSION}
data-tasty={TASTY_VERSION}
data-font-display={fontDisplay}
{...filterBaseProps(props, { eventProps: true })}
styles={styles}
style={{
'--pointer': cursorStrategy === 'web' ? 'pointer' : 'default',
...style,
}}
tokens={tokens}
>
<GlobalStyles
bodyStyles={bodyStyles}
publicUrl={publicUrl}
fonts={fonts}
font={font}
monospaceFont={monospaceFont}
fontDisplay={fontDisplay}
/>
<ModalProvider>
<PortalProvider value={ref}>
<EventBusProvider>
<OverlayProvider>
<AlertDialogApiProvider>{children}</AlertDialogApiProvider>
</OverlayProvider>
</EventBusProvider>
</PortalProvider>
</ModalProvider>
</RootElement>
</TrackingProvider>
</Provider>
</I18nProvider>
<TastyBatchProvider>
<I18nProvider i18n={i18n} locale={locale}>
<Provider navigation={navigation} root={rootRef}>
<TrackingProvider event={tracking?.event}>
<RootElement
ref={ref}
data-uikit={VERSION}
data-tasty={TASTY_VERSION}
data-font-display={fontDisplay}
{...filterBaseProps(props, { eventProps: true })}
styles={styles}
style={{
'--pointer': cursorStrategy === 'web' ? 'pointer' : 'default',
...style,
}}
tokens={tokens}
>
<GlobalStyles
bodyStyles={bodyStyles}
publicUrl={publicUrl}
fonts={fonts}
font={font}
monospaceFont={monospaceFont}
fontDisplay={fontDisplay}
/>
<ModalProvider>
<PortalProvider value={ref}>
<EventBusProvider>
<OverlayProvider>
<AlertDialogApiProvider>
{children}
</AlertDialogApiProvider>
</OverlayProvider>
</EventBusProvider>
</PortalProvider>
</ModalProvider>
</RootElement>
</TrackingProvider>
</Provider>
</I18nProvider>
</TastyBatchProvider>
);
}
14 changes: 11 additions & 3 deletions src/components/portal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TastyBatchProvider } from '@tenphi/tasty';
import { createPortal } from 'react-dom';

import { PortalProps } from './types';
Expand Down Expand Up @@ -29,8 +30,15 @@ import { usePortal } from './usePortal';
export function Portal(props: PortalProps) {
const { children, mountRoot, isDisabled } = usePortal(props);

if (isDisabled) return <>{children}</>;
// Overlays are where injection and measurement interleave worst: a dialog or
// tooltip mounts a fresh subtree and react-aria positions it from a layout
// effect in that same commit. `<Root>`'s batch window does not cover those
// commits — it does not re-render for them — so open one here. It flushes in
// `useInsertionEffect`, before any positioning effect reads the DOM.
const content = <TastyBatchProvider>{children}</TastyBatchProvider>;

if (isDisabled) return content;
// Render inline until mountRoot is available (fixes timing issues in tests and SSR)
if (!mountRoot) return <>{children}</>;
return createPortal(children, mountRoot);
if (!mountRoot) return content;
return createPortal(content, mountRoot);
Comment thread
cursor[bot] marked this conversation as resolved.
}
Loading