From 2eb6c28e42fe907ec1b44fa1de207f3899e89975 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 19:17:53 +0200 Subject: [PATCH 1/4] perf(Root,Portal): batch stylesheet writes into one invalidation per commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Enable tasty's `batchInjection` and open a batch window in for its own commits, plus one in for every overlay that mounts. Overlays are where this interleaving is worst: a dialog or tooltip mounts a fresh subtree and react-aria positions it from a layout effect in the same commit, and does not re-render for those commits so its window cannot cover them. 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. A commit with no window in it writes straight through exactly as before, and SSR is unaffected: styles are collected as text there and the provider is inert without a document. Root.browser.test.tsx asserts on the write queue itself, so the wiring cannot silently degrade into a no-op, and on getBoundingClientRect() inside a layout effect, which is the property that makes batching safe to turn on. Pins @tenphi/tasty to the PR snapshot that carries the feature. NOT FOR MERGE until that lands in a release. Co-Authored-By: Claude Opus 5 --- .changeset/batched-style-injection.md | 21 ++++ package.json | 2 +- pnpm-lock.yaml | 16 +-- src/components/Root.browser.test.tsx | 149 ++++++++++++++++++++++++++ src/components/Root.tsx | 86 ++++++++------- src/components/portal/Portal.tsx | 14 ++- 6 files changed, 239 insertions(+), 49 deletions(-) create mode 100644 .changeset/batched-style-injection.md create mode 100644 src/components/Root.browser.test.tsx diff --git a/.changeset/batched-style-injection.md b/.changeset/batched-style-injection.md new file mode 100644 index 000000000..e6172fc77 --- /dev/null +++ b/.changeset/batched-style-injection.md @@ -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. + +`` now enables tasty's `batchInjection` and opens a batch window for its +own commits, and `` 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`. diff --git a/package.json b/package.json index 61e6436bf..c96bd98cc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5514ccefc..cc944b33e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,8 +51,8 @@ importers: specifier: 2.0.0 version: 2.0.0 '@tenphi/tasty': - specifier: ^3.1.0 - version: 3.1.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) + specifier: 0.0.0-snapshot.a500c0a + version: 0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) clipboard-copy: specifier: ^4.0.1 version: 4.0.1 @@ -158,7 +158,7 @@ importers: version: 10.3.6(esbuild@0.27.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3)(vite@8.0.0(@types/node@24.13.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.31.1)(yaml@2.9.0))(webpack@5.76.1(esbuild@0.27.3)) '@tenphi/eslint-plugin-tasty': specifier: ^1.0.3 - version: 1.0.3(@tenphi/tasty@3.1.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) + version: 1.0.3(@tenphi/tasty@0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -2186,8 +2186,8 @@ packages: resolution: {integrity: sha512-entP+7nsScY87maev/rA6HS/qQy3YwHeywAsWTT9LGhovMp3/cj/Zb1jctlc37FWLim+mkYMwTGOk7ZfJBLLWg==} engines: {node: '>=20'} - '@tenphi/tasty@3.1.0': - resolution: {integrity: sha512-T+k0DbJRiODaCzj3h0N73nSznuRQ9W1iGGcEErz7o5aYf6bzH7YgDxdN3Hrm724QGbtP+W4weJOmgJHj50v29Q==} + '@tenphi/tasty@0.0.0-snapshot.a500c0a': + resolution: {integrity: sha512-rUVs4TTMtT132xB6uWVZ7P5UI2wJwbjQBOEi3DJJb3C4n8X5Zq4kAwg+ODmgO2K5L5aGY8XD4iMKWbmRwz5ovw==} engines: {node: '>=20'} peerDependencies: '@babel/core': ^7.24.0 @@ -8282,20 +8282,20 @@ snapshots: '@tanstack/virtual-core@3.13.12': {} - '@tenphi/eslint-plugin-tasty@1.0.3(@tenphi/tasty@3.1.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3)': + '@tenphi/eslint-plugin-tasty@1.0.3(@tenphi/tasty@0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3)': dependencies: '@typescript-eslint/utils': 8.56.1(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) eslint: 10.8.0(jiti@2.6.1) jiti: 2.6.1 optionalDependencies: - '@tenphi/tasty': 3.1.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) + '@tenphi/tasty': 0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) transitivePeerDependencies: - supports-color - typescript '@tenphi/glaze@2.0.0': {} - '@tenphi/tasty@3.1.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1)': + '@tenphi/tasty@0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1)': dependencies: csstype: 3.1.2 optionalDependencies: diff --git a/src/components/Root.browser.test.tsx b/src/components/Root.browser.test.tsx new file mode 100644 index 000000000..9c334309b --- /dev/null +++ b/src/components/Root.browser.test.tsx @@ -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(null); + useLayoutEffect(() => { + record(ref.current!.getBoundingClientRect().width); + }, []); + return ; + }; +} + +describe('Root batched injection', () => { + it('batches during the mount commit', () => { + const Box = tasty({ styles: { letterSpacing: '0.013em' } }); + let pendingMidRender: boolean | null = null; + + // Renders after , so anything Box queued is still queued here. + function Probe() { + pendingMidRender = hasPendingStyleWrites(); + return null; + } + + render( + + + + , + ); + + 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( + + + , + ); + + 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 ( + + + + + ); + } + + render( + + + , + ); + + 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 ? ( + + + + ) : null; + } + + render( + + + , + ); + + act(() => open(true)); + + expect(measured).toBe(WIDTH); + }); +}); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 04db1d9fc..ec5d37e7d 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -6,6 +6,7 @@ import { filterBaseProps, setGlobalPredefinedStates, tasty, + TastyBatchProvider, } from '@tenphi/tasty'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ModalProvider } from 'react-aria'; @@ -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 + // `` window — `` opens one below, and `` + // 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)', @@ -203,42 +211,46 @@ export function Root(allProps: CubeRootProps) { const styles = extractStyles(props, STYLES); return ( - - - - - - - - - - {children} - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + ); } diff --git a/src/components/portal/Portal.tsx b/src/components/portal/Portal.tsx index 0cdeec58e..d52dd30b3 100644 --- a/src/components/portal/Portal.tsx +++ b/src/components/portal/Portal.tsx @@ -1,3 +1,4 @@ +import { TastyBatchProvider } from '@tenphi/tasty'; import { createPortal } from 'react-dom'; import { PortalProps } from './types'; @@ -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. ``'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 = {children}; + + 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); } From d4c4913df603bbc50d03a331b12a73796f23cb02 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 19:45:35 +0200 Subject: [PATCH 2/4] test(Root): cover StrictMode, repin tasty to the post-fix snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot this branch pinned predated a fix in tenphi/tasty#269: a batch window opened during render survived its commit under StrictMode, because StrictMode double-invokes render but runs useInsertionEffect once. The next commit with no provider in it then got 'always' semantics silently, and its layout effect measured an unstyled box. Consumers develop in StrictMode, so that was the common path, not an edge case. The new test is the integration-level proof: it fails against the old snapshot and passes against the new one. Worth keeping permanently — nothing else in the suite renders under StrictMode, so a regression here would otherwise only show up in a consumer's dev build. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- pnpm-lock.yaml | 16 +++++++------- src/components/Root.browser.test.tsx | 32 +++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index c96bd98cc..8770e6b44 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@tabler/icons-react": "^3.31.0", "@tanstack/react-virtual": "^3.13.12", "@tenphi/glaze": "2.0.0", - "@tenphi/tasty": "0.0.0-snapshot.a500c0a", + "@tenphi/tasty": "0.0.0-snapshot.97099c6", "clipboard-copy": "^4.0.1", "clsx": "^1.1.1", "diff": "^8.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc944b33e..c3700c790 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,8 +51,8 @@ importers: specifier: 2.0.0 version: 2.0.0 '@tenphi/tasty': - specifier: 0.0.0-snapshot.a500c0a - version: 0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) + specifier: 0.0.0-snapshot.97099c6 + version: 0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) clipboard-copy: specifier: ^4.0.1 version: 4.0.1 @@ -158,7 +158,7 @@ importers: version: 10.3.6(esbuild@0.27.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3)(vite@8.0.0(@types/node@24.13.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.31.1)(yaml@2.9.0))(webpack@5.76.1(esbuild@0.27.3)) '@tenphi/eslint-plugin-tasty': specifier: ^1.0.3 - version: 1.0.3(@tenphi/tasty@0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) + version: 1.0.3(@tenphi/tasty@0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -2186,8 +2186,8 @@ packages: resolution: {integrity: sha512-entP+7nsScY87maev/rA6HS/qQy3YwHeywAsWTT9LGhovMp3/cj/Zb1jctlc37FWLim+mkYMwTGOk7ZfJBLLWg==} engines: {node: '>=20'} - '@tenphi/tasty@0.0.0-snapshot.a500c0a': - resolution: {integrity: sha512-rUVs4TTMtT132xB6uWVZ7P5UI2wJwbjQBOEi3DJJb3C4n8X5Zq4kAwg+ODmgO2K5L5aGY8XD4iMKWbmRwz5ovw==} + '@tenphi/tasty@0.0.0-snapshot.97099c6': + resolution: {integrity: sha512-Jt7td1zZOyqNtSmp7oGavZiNaE3ONfwxn7aASO6zS6+rYdHEB/POGL2EpL9J+XdoA5gDUZuAUropxSYSIcnTzw==} engines: {node: '>=20'} peerDependencies: '@babel/core': ^7.24.0 @@ -8282,20 +8282,20 @@ snapshots: '@tanstack/virtual-core@3.13.12': {} - '@tenphi/eslint-plugin-tasty@1.0.3(@tenphi/tasty@0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3)': + '@tenphi/eslint-plugin-tasty@1.0.3(@tenphi/tasty@0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3)': dependencies: '@typescript-eslint/utils': 8.56.1(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) eslint: 10.8.0(jiti@2.6.1) jiti: 2.6.1 optionalDependencies: - '@tenphi/tasty': 0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) + '@tenphi/tasty': 0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) transitivePeerDependencies: - supports-color - typescript '@tenphi/glaze@2.0.0': {} - '@tenphi/tasty@0.0.0-snapshot.a500c0a(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1)': + '@tenphi/tasty@0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1)': dependencies: csstype: 3.1.2 optionalDependencies: diff --git a/src/components/Root.browser.test.tsx b/src/components/Root.browser.test.tsx index 9c334309b..b0197c426 100644 --- a/src/components/Root.browser.test.tsx +++ b/src/components/Root.browser.test.tsx @@ -1,6 +1,6 @@ import { hasPendingStyleWrites, tasty } from '@tenphi/tasty'; import { act, render } from '@testing-library/react'; -import { useLayoutEffect, useRef, useState } from 'react'; +import { StrictMode, useLayoutEffect, useRef, useState } from 'react'; import { Portal } from './portal'; import { Root } from './Root'; @@ -118,6 +118,36 @@ describe('Root batched injection', () => { expect(hasPendingStyleWrites()).toBe(false); }); + // Dev runs under StrictMode, which double-invokes render but runs insertion + // effects once. If a batch window survived its commit, the next commit with no + // provider in it would quietly get 'always' semantics and its layout effect + // would measure an unstyled box — a dev-only wrong number that never + // self-corrects. Consumers develop in StrictMode, so this is the common path. + it('keeps measurement correct after a StrictMode commit', () => { + const WIDTH = 211; + const Box = tasty({ styles: { width: `${WIDTH}px`, height: '10px' } }); + let measured = -1; + + function Measured() { + const ref = useRef(null); + useLayoutEffect(() => { + measured = ref.current!.getBoundingClientRect().width; + }, []); + return ; + } + + render( + + + , + ); + + // A separate commit, outside any provider. + render(); + + expect(measured).toBe(WIDTH); + }); + it('measures correctly inside a portal that mounts later', () => { let measured = -1; let open: (value: boolean) => void = () => {}; From 37deb99d64241b83266bab7d22836f81296ac955 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 19:56:32 +0200 Subject: [PATCH 3/4] fix(Overlay): open a batch window where the overlays actually portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot caught that the PR's central claim was wrong. Popover, Modal and Tray portal through Overlay's own raw createPortal, not through , so DialogTrigger and MenuTrigger — the surfaces named as benefiting most — got no batch window at all. turns out to cover tooltips only: TooltipTrigger is its single consumer among the components. Open a window in Overlay too. The new DialogTrigger test drives a real popover and fails without it, which the existing test could not do: that one passes whether or not the overlay path is covered, so it validated the mechanism while leaving the claim untested. Windows have to be opened per portal boundary, since a commit that mounts a portal does not re-render . Three are now open — Root, Portal, Overlay — and the comments and changeset say which surfaces each one covers rather than implying blanket coverage. Still uncovered by design: Board's WidgetHost drag clone, LayoutPanel, and use-context-menu's anchor, none of which position themselves off a same-commit measurement. Co-Authored-By: Claude Opus 5 --- .changeset/batched-style-injection.md | 15 ++++++--- src/components/Root.browser.test.tsx | 40 ++++++++++++++++++++++- src/components/Root.tsx | 10 +++--- src/components/overlays/Modal/Overlay.tsx | 13 +++++++- src/components/portal/Portal.tsx | 12 ++++--- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/.changeset/batched-style-injection.md b/.changeset/batched-style-injection.md index e6172fc77..7ff5af261 100644 --- a/.changeset/batched-style-injection.md +++ b/.changeset/batched-style-injection.md @@ -11,11 +11,16 @@ autosizing, a virtualized table measuring rows — the two interleave and the browser is forced to recalculate style between every injection. `` now enables tasty's `batchInjection` and opens a batch window for its -own commits, and `` 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. +own commits. A commit that mounts a portal does not re-render ``, so +windows are opened per portal boundary too: `` (tooltips) and `` +(popovers, modals and trays — the `Dialog` and `Menu` surfaces). Those are the +commits where injection and measurement interleave worst, because react-aria +positions the overlay from a layout effect in the same commit that mounts it. + +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`. diff --git a/src/components/Root.browser.test.tsx b/src/components/Root.browser.test.tsx index b0197c426..331f5e81e 100644 --- a/src/components/Root.browser.test.tsx +++ b/src/components/Root.browser.test.tsx @@ -1,7 +1,10 @@ import { hasPendingStyleWrites, tasty } from '@tenphi/tasty'; -import { act, render } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { StrictMode, useLayoutEffect, useRef, useState } from 'react'; +import { Button } from './actions/Button'; +import { Dialog, DialogTrigger } from './overlays/Dialog'; import { Portal } from './portal'; import { Root } from './Root'; @@ -118,6 +121,41 @@ describe('Root batched injection', () => { expect(hasPendingStyleWrites()).toBe(false); }); + // The claim this change rests on is that *overlays* benefit, and they do not + // go through : Popover, Modal and Tray portal through Overlay's raw + // createPortal, so DialogTrigger and MenuTrigger are covered only by the + // window Overlay opens. Driving a real DialogTrigger is the only way to assert + // that — a test passes whether or not the overlay path is covered. + it('batches the commit a real dialog mounts in', async () => { + const Box = tasty({ styles: { letterSpacing: '0.037em' } }); + const observed: boolean[] = []; + + function Probe() { + observed.push(hasPendingStyleWrites()); + return null; + } + + render( + + + + + + + + + , + ); + + expect(observed).toEqual([]); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + await waitFor(() => expect(observed.length).toBeGreaterThan(0)); + + expect(observed).toContain(true); + expect(hasPendingStyleWrites()).toBe(false); + }); + // Dev runs under StrictMode, which double-invokes render but runs insertion // effects once. If a batch window survived its commit, the next commit with no // provider in it would quietly get 'always' semantics and its layout effect diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ec5d37e7d..0550a41bf 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -43,10 +43,12 @@ 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 - // `` window — `` opens one below, and `` - // 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. + // `` window, and windows have to be opened per portal + // boundary because a commit that mounts a portal does not re-render ``. + // The kit opens three: here, in `` (tooltips) and in `` + // (popovers, modals, trays — i.e. Dialog and Menu). Writes in any commit + // without a window go straight through exactly as before, so a + // `useLayoutEffect` can never measure an element whose rules have not landed. batchInjection: true, units: { x: 'var(--gap)', diff --git a/src/components/overlays/Modal/Overlay.tsx b/src/components/overlays/Modal/Overlay.tsx index 7214af074..e02534ddb 100644 --- a/src/components/overlays/Modal/Overlay.tsx +++ b/src/components/overlays/Modal/Overlay.tsx @@ -1,3 +1,4 @@ +import { TastyBatchProvider } from '@tenphi/tasty'; import { Children, cloneElement, @@ -111,7 +112,17 @@ function Overlay(props: CubeOverlayProps, ref) { ); - return createPortal(contents, container || root || document.body); + // Popover, Modal and Tray all portal through here, which makes this the + // overlay path that matters most for batching: a dialog or menu mounts a + // fresh subtree and react-aria positions it from a layout effect in the same + // commit. `` does not re-render for those commits, so open a window + // here — it flushes in `useInsertionEffect`, before any positioning effect + // reads the DOM. Note this is a *raw* `createPortal`, not ``, so the + // window `` opens does not reach these overlays. + return createPortal( + {contents}, + container || root || document.body, + ); } let _Overlay = forwardRef(Overlay); diff --git a/src/components/portal/Portal.tsx b/src/components/portal/Portal.tsx index d52dd30b3..04a057b95 100644 --- a/src/components/portal/Portal.tsx +++ b/src/components/portal/Portal.tsx @@ -30,11 +30,13 @@ import { usePortal } from './usePortal'; export function Portal(props: PortalProps) { const { children, mountRoot, isDisabled } = usePortal(props); - // 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. ``'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. + // A portal mounts a fresh subtree in a commit that did not re-render ``, + // so its window cannot cover this one — open one here, flushing in + // `useInsertionEffect` before any positioning effect reads the DOM. + // + // In the kit this path is tooltips: `TooltipTrigger` is the only component + // that renders ``. Popovers, modals and trays portal through + // ``'s own `createPortal`, which opens its own window. const content = {children}; if (isDisabled) return content; From 57c5710a9a344e4695fbd570e6eb5e9c196ab351 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 20:09:13 +0200 Subject: [PATCH 4/4] chore(deps): update @tenphi/tasty to 3.2.0 Batched injection shipped in tasty 3.2.0, so this branch no longer needs a PR snapshot. Drops the last reason it was a draft. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 8770e6b44..3b61a1663 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@tabler/icons-react": "^3.31.0", "@tanstack/react-virtual": "^3.13.12", "@tenphi/glaze": "2.0.0", - "@tenphi/tasty": "0.0.0-snapshot.97099c6", + "@tenphi/tasty": "3.2.0", "clipboard-copy": "^4.0.1", "clsx": "^1.1.1", "diff": "^8.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3700c790..7f83598a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,8 +51,8 @@ importers: specifier: 2.0.0 version: 2.0.0 '@tenphi/tasty': - specifier: 0.0.0-snapshot.97099c6 - version: 0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) + specifier: 3.2.0 + version: 3.2.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) clipboard-copy: specifier: ^4.0.1 version: 4.0.1 @@ -158,7 +158,7 @@ importers: version: 10.3.6(esbuild@0.27.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3)(vite@8.0.0(@types/node@24.13.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.31.1)(yaml@2.9.0))(webpack@5.76.1(esbuild@0.27.3)) '@tenphi/eslint-plugin-tasty': specifier: ^1.0.3 - version: 1.0.3(@tenphi/tasty@0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) + version: 1.0.3(@tenphi/tasty@3.2.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -2186,8 +2186,8 @@ packages: resolution: {integrity: sha512-entP+7nsScY87maev/rA6HS/qQy3YwHeywAsWTT9LGhovMp3/cj/Zb1jctlc37FWLim+mkYMwTGOk7ZfJBLLWg==} engines: {node: '>=20'} - '@tenphi/tasty@0.0.0-snapshot.97099c6': - resolution: {integrity: sha512-Jt7td1zZOyqNtSmp7oGavZiNaE3ONfwxn7aASO6zS6+rYdHEB/POGL2EpL9J+XdoA5gDUZuAUropxSYSIcnTzw==} + '@tenphi/tasty@3.2.0': + resolution: {integrity: sha512-pBeDy1in5FGS74ofma6O7NqFpvfdfL9oLgI84bAhWcX5PwMw32jZaswUeCHQGHVEGYioGXMUCy4DwYjXs7B5gw==} engines: {node: '>=20'} peerDependencies: '@babel/core': ^7.24.0 @@ -8282,20 +8282,20 @@ snapshots: '@tanstack/virtual-core@3.13.12': {} - '@tenphi/eslint-plugin-tasty@1.0.3(@tenphi/tasty@0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3)': + '@tenphi/eslint-plugin-tasty@1.0.3(@tenphi/tasty@3.2.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1))(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3)': dependencies: '@typescript-eslint/utils': 8.56.1(eslint@10.8.0(jiti@2.6.1))(typescript@5.6.3) eslint: 10.8.0(jiti@2.6.1) jiti: 2.6.1 optionalDependencies: - '@tenphi/tasty': 0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) + '@tenphi/tasty': 3.2.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1) transitivePeerDependencies: - supports-color - typescript '@tenphi/glaze@2.0.0': {} - '@tenphi/tasty@0.0.0-snapshot.97099c6(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1)': + '@tenphi/tasty@3.2.0(@babel/core@7.28.5)(@babel/helper-plugin-utils@7.25.7)(@babel/types@7.29.0)(jiti@2.6.1)(react@19.1.1)': dependencies: csstype: 3.1.2 optionalDependencies: