From b3e97d85206c3c03cf6eec24ed06223c267aa51d Mon Sep 17 00:00:00 2001 From: "Roger Tuan (DatoCMS)" Date: Mon, 14 Sep 2026 15:18:34 -0700 Subject: [PATCH 1/3] Plugin SDK fixes: raised input surface, ctx.rootFontSize, message-origin fast path - react-ui TextInput/TextareaInput/SelectInput paint on the raised surface token, matching native inputs (dark-mode issue #54). - New ctx.rootFontSize; the runtime mirrors the host root font size onto the plugin so rem-based tokens match the host (community thread 8663). - Read event.origin before Penpal reads event.data so Chrome skips its slow cross-origin deserialization path (issue #55). - Changesets included. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RtS2SCFb7RgPeThjnPVngz --- .changeset/prime-message-origin.md | 5 ++ .changeset/raised-form-controls.md | 5 ++ .changeset/root-font-size.md | 5 ++ packages/react-ui/src/Canvas/index.tsx | 4 +- .../react-ui/src/Dropdown/styles.module.css | 2 +- packages/react-ui/src/SelectInput/index.tsx | 4 +- .../react-ui/src/TextInput/styles.module.css | 2 +- .../src/TextareaInput/styles.module.css | 2 +- packages/sdk/manifest.json | 58 ++++++++++-------- packages/sdk/src/connect.ts | 49 +++++++++++++-- packages/sdk/src/ctx/base.ts | 13 ++++ packages/sdk/src/manifest.ts | 59 +++++++++++-------- 12 files changed, 148 insertions(+), 60 deletions(-) create mode 100644 .changeset/prime-message-origin.md create mode 100644 .changeset/raised-form-controls.md create mode 100644 .changeset/root-font-size.md diff --git a/.changeset/prime-message-origin.md b/.changeset/prime-message-origin.md new file mode 100644 index 0000000..4f7ba9a --- /dev/null +++ b/.changeset/prime-message-origin.md @@ -0,0 +1,5 @@ +--- +"datocms-plugin-sdk": patch +--- + +Faster host → plugin messaging in Chromium. Chrome deliberately slows the deserialization of cross-origin messages of 16 KB or more when `event.data` is read before `event.origin` (it re-deserializes the payload 4-8 times to mask timing), and Penpal 4 reads `data` first. The SDK now registers a capture-phase `message` listener that reads `event.origin` before Penpal runs, which makes every host message take the fast path: a 266 KB ctx went from ~7 ms to ~1 ms per message in Chrome. Firefox and Safari deserialize eagerly and are unaffected. diff --git a/.changeset/raised-form-controls.md b/.changeset/raised-form-controls.md new file mode 100644 index 0000000..8d5d8e8 --- /dev/null +++ b/.changeset/raised-form-controls.md @@ -0,0 +1,5 @@ +--- +"datocms-react-ui": patch +--- + +`TextInput`, `TextareaInput` and `SelectInput` now paint on `--color--surface-raised`, the same token native DatoCMS form controls use. In dark mode they were one step darker than the controls around them. diff --git a/.changeset/root-font-size.md b/.changeset/root-font-size.md new file mode 100644 index 0000000..11ebebb --- /dev/null +++ b/.changeset/root-font-size.md @@ -0,0 +1,5 @@ +--- +"datocms-plugin-sdk": minor +--- + +New `ctx.rootFontSize`: the font size of the host's `` element, in CSS pixels. The host steps it with the viewport width (14.4, 15 or 16 px), and the SDK runtime now mirrors it onto the plugin's ``, so `rem` units and every rem-based token (`--font-size-*`, `--spacing-*`) measure the same inside the frame as in the surrounding UI. diff --git a/packages/react-ui/src/Canvas/index.tsx b/packages/react-ui/src/Canvas/index.tsx index e58b601..3849afe 100644 --- a/packages/react-ui/src/Canvas/index.tsx +++ b/packages/react-ui/src/Canvas/index.tsx @@ -104,14 +104,14 @@ export type CanvasProps = { * collapsible={{ isOpen, onToggle: () => setOpen((v) => !v) }} * > *

- * One-level tokens that work on any neutral page. The surface, ink and border families cover the page background, body text and dividers; the surface-raised variants belong to the elevated layer used by modals, dropdowns and popovers. The tone-on-neutral inks (ink-danger, ink-warning, ink-success) color text and icons on a neutral surface; inside a toned panel use that context's own ink instead. + * One-level tokens that work on any neutral page. The surface, ink and border families cover the page background, body text and dividers; the surface-raised variants belong to the elevated layer used by form controls, modals, dropdowns and popovers. The tone-on-neutral inks (ink-danger, ink-warning, ink-success) color text and icons on a neutral surface; inside a toned panel use that context's own ink instead. *

* { : 'var(--color--focus--border)', backgroundColor: isDisabled ? 'var(--color--disabled--surface)' - : 'var(--color--surface)', + : 'var(--color--surface-raised)', boxShadow: `0 0 0 4px ${ error ? 'var(--color--danger-soft--outline)' @@ -81,7 +81,7 @@ const useStyles = (isDisabled?: boolean, error?: boolean) => { : 'var(--color--border)', backgroundColor: isDisabled ? 'var(--color--disabled--surface)' - : 'var(--color--surface)', + : 'var(--color--surface-raised)', '&:hover': { borderColor: error ? 'var(--color--danger-soft--border)' diff --git a/packages/react-ui/src/TextInput/styles.module.css b/packages/react-ui/src/TextInput/styles.module.css index dde70b4..4c90a2b 100644 --- a/packages/react-ui/src/TextInput/styles.module.css +++ b/packages/react-ui/src/TextInput/styles.module.css @@ -6,7 +6,7 @@ border: 1px solid var(--color--border); appearance: none; border-radius: 0; - background-color: var(--color--surface); + background-color: var(--color--surface-raised); background-image: none; transition: border 0.2s var(--material-ease); font-size: var(--font-size-m); diff --git a/packages/react-ui/src/TextareaInput/styles.module.css b/packages/react-ui/src/TextareaInput/styles.module.css index 6f01057..ca66fe0 100644 --- a/packages/react-ui/src/TextareaInput/styles.module.css +++ b/packages/react-ui/src/TextareaInput/styles.module.css @@ -6,7 +6,7 @@ border: 1px solid var(--color--border); appearance: none; border-radius: 0; - background-color: var(--color--surface); + background-color: var(--color--surface-raised); background-image: none; transition: border 0.2s var(--material-ease); font-size: var(--font-size-m); diff --git a/packages/sdk/manifest.json b/packages/sdk/manifest.json index bcc70d3..5407a5c 100644 --- a/packages/sdk/manifest.json +++ b/packages/sdk/manifest.json @@ -3157,6 +3157,16 @@ "lineNumber": 134 }, "type": "'light' | 'dark'" + }, + "rootFontSize": { + "comment": { + "markdownText": "Font size of the host's `` element, in CSS pixels. The host steps\nit with the viewport width, so the SDK runtime mirrors it onto the\nplugin's ``: `rem` units, and with them every `--font-size-*` and\n`--spacing-*` token, then measure the same inside the frame as outside.\n\nTyped as required for parity with the other appearance fields (e.g.\n`colorScheme`), but only hosts new enough to send it populate it: a plugin\nrunning inside an older host reads `undefined` at runtime. Guard for that\nif you consume the value directly." + }, + "location": { + "filePath": "src/ctx/base.ts", + "lineNumber": 147 + }, + "type": "number" } } }, @@ -3172,7 +3182,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 144 + "lineNumber": 157 }, "type": "Partial>" }, @@ -3182,7 +3192,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 151 + "lineNumber": 164 }, "type": "Partial>" }, @@ -3192,7 +3202,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 158 + "lineNumber": 171 }, "type": "Partial>" }, @@ -3202,7 +3212,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 165 + "lineNumber": 178 }, "type": "Partial>" }, @@ -3212,7 +3222,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 172 + "lineNumber": 185 }, "type": "Partial>" } @@ -3233,7 +3243,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 235 + "lineNumber": 248 }, "type": "(itemTypeId: string) => Promise" }, @@ -3244,7 +3254,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 254 + "lineNumber": 267 }, "type": "(itemTypeId: string) => Promise" }, @@ -3255,7 +3265,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 271 + "lineNumber": 284 }, "type": "() => Promise" }, @@ -3266,7 +3276,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 284 + "lineNumber": 297 }, "type": "() => Promise" }, @@ -3277,7 +3287,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 297 + "lineNumber": 310 }, "type": "() => Promise" } @@ -3296,7 +3306,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 319 + "lineNumber": 332 }, "type": "(params: Record) => Promise" }, @@ -3307,7 +3317,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 370 + "lineNumber": 383 }, "type": "(\n fieldId: string,\n changes: FieldAppearanceChange[],\n ) => Promise" } @@ -3326,7 +3336,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 475 + "lineNumber": 488 }, "type": "(message: string) => Promise" }, @@ -3337,7 +3347,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 490 + "lineNumber": 503 }, "type": "(message: string) => Promise" }, @@ -3348,7 +3358,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 514 + "lineNumber": 527 }, "type": "(\n toast: Toast,\n ) => Promise" } @@ -3367,7 +3377,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 400 + "lineNumber": 413 }, "type": "(itemTypeId: string) => Promise" }, @@ -3378,7 +3388,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 421 + "lineNumber": 434 }, "type": "{\n (\n itemTypeId: string,\n options: { multiple: true; initialLocationQuery?: ItemListLocationQuery },\n ): Promise;\n (\n itemTypeId: string,\n options?: {\n multiple: false;\n initialLocationQuery?: ItemListLocationQuery;\n },\n ): Promise;\n }" }, @@ -3389,7 +3399,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 453 + "lineNumber": 466 }, "type": "(itemId: string) => Promise" } @@ -3408,7 +3418,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 541 + "lineNumber": 554 }, "type": "{\n (options: { multiple: true }): Promise;\n (options?: { multiple: false }): Promise;\n }" }, @@ -3419,7 +3429,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 569 + "lineNumber": 582 }, "type": "(\n uploadId: string,\n ) => Promise<(Upload & { deleted?: true }) | null>" }, @@ -3430,7 +3440,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 598 + "lineNumber": 611 }, "type": "(\n /** The \"single asset\" field structure */\n fileFieldValue: FileFieldValue,\n /** Shows metadata information for a specific locale */\n locale?: string,\n ) => Promise" } @@ -3449,7 +3459,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 629 + "lineNumber": 642 }, "type": "(modal: Modal) => Promise" }, @@ -3460,7 +3470,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 666 + "lineNumber": 679 }, "type": "(options: ConfirmOptions) => Promise" } @@ -3479,7 +3489,7 @@ }, "location": { "filePath": "src/ctx/base.ts", - "lineNumber": 680 + "lineNumber": 693 }, "type": "(path: string) => Promise" } diff --git a/packages/sdk/src/connect.ts b/packages/sdk/src/connect.ts index aa39cb7..c2f6374 100644 --- a/packages/sdk/src/connect.ts +++ b/packages/sdk/src/connect.ts @@ -136,10 +136,36 @@ export type FullConnectParameters = AssetSourcesHook & UploadSidebarsHook & ValidateManualFieldExtensionParametersHook; -function applyColorScheme(properties: unknown): void { +type HostAppearance = { + colorScheme?: 'light' | 'dark'; + rootFontSize?: number; +}; + +/** + * Mirrors the host's appearance onto the plugin's ``: color scheme and + * root font size. Runs on the first settings read and on every `onChange`. + */ +function applyHostAppearance(properties: unknown): void { if (typeof document === 'undefined') return; - const next = (properties as { colorScheme?: 'light' | 'dark' } | null) - ?.colorScheme; + const { colorScheme, rootFontSize } = + (properties as HostAppearance | null) ?? {}; + applyColorScheme(colorScheme); + applyRootFontSize(rootFontSize); +} + +function applyRootFontSize(next: number | undefined): void { + // Hosts that predate the property leave the plugin's own default in place. + // Reject non-positive/non-finite values too: a host bug or a transient 0 + // during boot would otherwise set `font-size: 0px` and collapse every + // rem-based size in the plugin to nothing. + if (typeof next !== 'number' || !(next > 0)) return; + // The host's font size steps with the viewport width; mirroring it + // makes rem-based sizes (every --font-size-* and --spacing-* token) measure + // the same on both sides of the iframe boundary. + document.documentElement.style.fontSize = `${next}px`; +} + +function applyColorScheme(next: 'light' | 'dark' | undefined): void { if (next !== 'light' && next !== 'dark') return; if (document.documentElement.dataset.colorScheme === next) return; document.documentElement.dataset.colorScheme = next; @@ -180,6 +206,18 @@ export async function connect( ), }; + // Chromium deliberately slows the deserialization of cross-origin messages + // of 16 KB or more when `event.data` is read before `event.origin`: it + // re-deserializes the payload 4-8 times in a throwaway isolate to mask + // timing (MaskDeserializationTimings in message_event.cc). Penpal 4 reads + // `data` first. `message` events target `window` directly, so this listener + // runs before Penpal's simply because it is registered first (added before + // connectToParent below); reading `origin` here clears the flag for the + // event, so every message takes the fast path. The value is returned, not + // discarded, so that minifiers with `pure_getters` enabled keep the read. + // No-op where deserialization is eager (Firefox, Safari). + window.addEventListener('message', (event) => event.origin, true); + const penpalConnection = connectToParent({ methods: { // Protocol revision, not the npm version: it's bumped by hand whenever @@ -204,7 +242,7 @@ export async function connect( ), ), onChange(newSettings: unknown) { - applyColorScheme(newSettings); + applyHostAppearance(newSettings); if (onChangeListener) { onChangeListener(newSettings); } @@ -231,8 +269,9 @@ export async function connect( }); const methods = await penpalConnection.promise; + const initialProperties = await methods.getSettings(); - applyColorScheme(initialProperties); + applyHostAppearance(initialProperties); if (initialProperties.mode === 'onBoot') { let currentProperties = initialProperties; diff --git a/packages/sdk/src/ctx/base.ts b/packages/sdk/src/ctx/base.ts index 3c569e9..0dfe1f5 100644 --- a/packages/sdk/src/ctx/base.ts +++ b/packages/sdk/src/ctx/base.ts @@ -132,6 +132,19 @@ type ProjectProperties = { * directly. */ colorScheme: 'light' | 'dark'; + + /** + * Font size of the host's `` element, in CSS pixels. The host steps + * it with the viewport width, so the SDK runtime mirrors it onto the + * plugin's ``: `rem` units, and with them every `--font-size-*` and + * `--spacing-*` token, then measure the same inside the frame as outside. + * + * Typed as required for parity with the other appearance fields (e.g. + * `colorScheme`), but only hosts new enough to send it populate it: a plugin + * running inside an older host reads `undefined` at runtime. Guard for that + * if you consume the value directly. + */ + rootFontSize: number; }; /** diff --git a/packages/sdk/src/manifest.ts b/packages/sdk/src/manifest.ts index 64cc9d1..6cc9ce3 100644 --- a/packages/sdk/src/manifest.ts +++ b/packages/sdk/src/manifest.ts @@ -3393,6 +3393,17 @@ export const manifest: Manifest = { }, type: "'light' | 'dark'", }, + rootFontSize: { + comment: { + markdownText: + "Font size of the host's `` element, in CSS pixels. The host steps\nit with the viewport width, so the SDK runtime mirrors it onto the\nplugin's ``: `rem` units, and with them every `--font-size-*` and\n`--spacing-*` token, then measure the same inside the frame as outside.\n\nTyped as required for parity with the other appearance fields (e.g.\n`colorScheme`), but only hosts new enough to send it populate it: a plugin\nrunning inside an older host reads `undefined` at runtime. Guard for that\nif you consume the value directly.", + }, + location: { + filePath: 'src/ctx/base.ts', + lineNumber: 147, + }, + type: 'number', + }, }, }, { @@ -3409,7 +3420,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 144, + lineNumber: 157, }, type: 'Partial>', }, @@ -3420,7 +3431,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 151, + lineNumber: 164, }, type: 'Partial>', }, @@ -3431,7 +3442,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 158, + lineNumber: 171, }, type: 'Partial>', }, @@ -3442,7 +3453,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 165, + lineNumber: 178, }, type: 'Partial>', }, @@ -3453,7 +3464,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 172, + lineNumber: 185, }, type: 'Partial>', }, @@ -3477,7 +3488,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 235, + lineNumber: 248, }, type: '(itemTypeId: string) => Promise', }, @@ -3490,7 +3501,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 254, + lineNumber: 267, }, type: '(itemTypeId: string) => Promise', }, @@ -3503,7 +3514,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 271, + lineNumber: 284, }, type: '() => Promise', }, @@ -3516,7 +3527,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 284, + lineNumber: 297, }, type: '() => Promise', }, @@ -3529,7 +3540,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 297, + lineNumber: 310, }, type: '() => Promise', }, @@ -3551,7 +3562,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 319, + lineNumber: 332, }, type: '(params: Record) => Promise', }, @@ -3564,7 +3575,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 370, + lineNumber: 383, }, type: '(\n fieldId: string,\n changes: FieldAppearanceChange[],\n ) => Promise', }, @@ -3586,7 +3597,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 475, + lineNumber: 488, }, type: '(message: string) => Promise', }, @@ -3599,7 +3610,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 490, + lineNumber: 503, }, type: '(message: string) => Promise', }, @@ -3612,7 +3623,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 514, + lineNumber: 527, }, type: '(\n toast: Toast,\n ) => Promise', }, @@ -3634,7 +3645,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 400, + lineNumber: 413, }, type: '(itemTypeId: string) => Promise', }, @@ -3647,7 +3658,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 421, + lineNumber: 434, }, type: '{\n (\n itemTypeId: string,\n options: { multiple: true; initialLocationQuery?: ItemListLocationQuery },\n ): Promise;\n (\n itemTypeId: string,\n options?: {\n multiple: false;\n initialLocationQuery?: ItemListLocationQuery;\n },\n ): Promise;\n }', }, @@ -3660,7 +3671,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 453, + lineNumber: 466, }, type: '(itemId: string) => Promise', }, @@ -3682,7 +3693,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 541, + lineNumber: 554, }, type: '{\n (options: { multiple: true }): Promise;\n (options?: { multiple: false }): Promise;\n }', }, @@ -3695,7 +3706,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 569, + lineNumber: 582, }, type: '(\n uploadId: string,\n ) => Promise<(Upload & { deleted?: true }) | null>', }, @@ -3708,7 +3719,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 598, + lineNumber: 611, }, type: '(\n /** The "single asset" field structure */\n fileFieldValue: FileFieldValue,\n /** Shows metadata information for a specific locale */\n locale?: string,\n ) => Promise', }, @@ -3730,7 +3741,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 629, + lineNumber: 642, }, type: '(modal: Modal) => Promise', }, @@ -3743,7 +3754,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 666, + lineNumber: 679, }, type: '(options: ConfirmOptions) => Promise', }, @@ -3764,7 +3775,7 @@ export const manifest: Manifest = { }, location: { filePath: 'src/ctx/base.ts', - lineNumber: 680, + lineNumber: 693, }, type: '(path: string) => Promise', }, From 32103540c2eaedff178fcd838fec426ecc3071bc Mon Sep 17 00:00:00 2001 From: "Roger Tuan (DatoCMS)" Date: Tue, 15 Sep 2026 16:13:53 -0700 Subject: [PATCH 2/3] fix(sdk): guard applyRootFontSize against per-keystroke reflow applyHostAppearance runs on every ctx update (every keystroke in a sibling field). Writing documentElement.style.fontSize unconditionally reflowed the whole rem-based plugin frame each time; only write when the value actually changed, matching applyColorScheme. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RtS2SCFb7RgPeThjnPVngz --- packages/sdk/src/connect.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/connect.ts b/packages/sdk/src/connect.ts index c2f6374..41a5872 100644 --- a/packages/sdk/src/connect.ts +++ b/packages/sdk/src/connect.ts @@ -162,7 +162,12 @@ function applyRootFontSize(next: number | undefined): void { // The host's font size steps with the viewport width; mirroring it // makes rem-based sizes (every --font-size-* and --spacing-* token) measure // the same on both sides of the iframe boundary. - document.documentElement.style.fontSize = `${next}px`; + const px = `${next}px`; + // `applyHostAppearance` runs on every ctx update (i.e. every keystroke in a + // sibling field). Writing `fontSize` unconditionally would reflow the whole + // rem-based frame each time, so only touch the DOM when the value changed. + if (document.documentElement.style.fontSize === px) return; + document.documentElement.style.fontSize = px; } function applyColorScheme(next: 'light' | 'dark' | undefined): void { From 20f0db050a6e0bb22109074c6abf9f03abda3745 Mon Sep 17 00:00:00 2001 From: "Roger Tuan (DatoCMS)" Date: Tue, 15 Sep 2026 17:00:08 -0700 Subject: [PATCH 3/3] fix(sdk): reject non-finite rootFontSize, matching the comment `!(next > 0)` let Infinity through; use Number.isFinite so the guard matches its stated intent (reject non-positive and non-finite values). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RtS2SCFb7RgPeThjnPVngz --- packages/sdk/src/connect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/connect.ts b/packages/sdk/src/connect.ts index 41a5872..9f19884 100644 --- a/packages/sdk/src/connect.ts +++ b/packages/sdk/src/connect.ts @@ -158,7 +158,7 @@ function applyRootFontSize(next: number | undefined): void { // Reject non-positive/non-finite values too: a host bug or a transient 0 // during boot would otherwise set `font-size: 0px` and collapse every // rem-based size in the plugin to nothing. - if (typeof next !== 'number' || !(next > 0)) return; + if (typeof next !== 'number' || !Number.isFinite(next) || next <= 0) return; // The host's font size steps with the viewport width; mirroring it // makes rem-based sizes (every --font-size-* and --spacing-* token) measure // the same on both sides of the iframe boundary.