diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ee466a..e190a84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.1.0] - 2026-09-01
+
+### Added
+- Scalar list field support (`isList` updates): String/ID/Enumeration/Int/Float/Boolean/Date/DateTime lists now
+ update live instead of being withheld by Studio
+- `data-hygraph-list-index` attribute and the `listIndex` option on `createPreviewAttributes`, to have each list item
+ patched individually
+- `scalarListSync` capability in the `ready` message, which is how Studio decides whether list updates are safe to send
+- `preview:field-updated` is now emitted after the built-in updater applies a change, and `preview:update-failed` when
+ it cannot; the update event detail carries `componentChain`, `isList`, `fieldType`, and `transformedValue`
+
+### Changed
+- A whole-list binding renders its items joined with `, `; a binding that has child elements is refused (reported via
+ `preview:update-failed`) rather than having its markup overwritten
+
## [1.0.1] - 2025-11-24
### Documentation
diff --git a/agents.md b/agents.md
index 64972a6..02a0067 100644
--- a/agents.md
+++ b/agents.md
@@ -51,6 +51,12 @@
- Value is a JSON string array of `{ fieldApiId, instanceId }` hops (outermost first).
- Prefer `createPreviewAttributes` and `createComponentChainLink` helpers from `@hygraph/preview-sdk/core` to generate the attributes safely.
+#### Scalar list fields
+- An element tagged with only the field api id binds the **whole** list: live updates render every item joined with `, `.
+- Tag one node per item with `data-hygraph-list-index` (or `listIndex` on `createPreviewAttributes`) to have each item patched individually.
+- The built-in updater refuses to write a list into an element that has child elements (a `
` of `- ` items, say) and reports `preview:update-failed` rather than destroying markup it cannot rebuild.
+- Adding or removing items needs the consumer's own rendering: handle `onFieldUpdate` or the `preview:field-updated` event, both of which carry the raw array.
+
### Save Handler Cheat Sheet
- Next.js App Router: `router.refresh()`.
```37:44:examples/nextjs-example/src/components/PreviewWrapper.tsx
diff --git a/package-lock.json b/package-lock.json
index bdfe2e8..282b5a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@hygraph/preview-sdk",
- "version": "1.0.8",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hygraph/preview-sdk",
- "version": "1.0.8",
+ "version": "1.1.0",
"license": "MIT",
"dependencies": {
"morphdom": "^2.7.0"
diff --git a/package.json b/package.json
index 47839b2..2cb6049 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@hygraph/preview-sdk",
- "version": "1.0.8",
+ "version": "1.1.0",
"description": "Content preview SDK for seamless real-time content editing in Hygraph CMS",
"publishConfig": {
"access": "public"
diff --git a/src/core/ContentUpdater.test.ts b/src/core/ContentUpdater.test.ts
index cfc2565..41116da 100644
--- a/src/core/ContentUpdater.test.ts
+++ b/src/core/ContentUpdater.test.ts
@@ -479,5 +479,152 @@ describe('ContentUpdater', () => {
expect(differentElement.innerHTML).toContain('Original');
});
});
-});
+ describe('Scalar list fields', () => {
+ it('renders a whole-list binding as a comma-separated string', async () => {
+ const element = createPreviewElement({
+ entryId: 'entry-list',
+ fieldApiId: 'tags',
+ textContent: 'Original',
+ });
+
+ const result = await updater.updateField({
+ entryId: 'entry-list',
+ fieldApiId: 'tags',
+ fieldType: 'STRING',
+ isList: true,
+ newValue: ['one', 'two', 'three'],
+ });
+
+ expect(result.success).toBe(true);
+ expect(element.textContent).toBe('one, two, three');
+ });
+
+ it('renders a whole-list binding into an input value', async () => {
+ const element = createPreviewElement({
+ entryId: 'entry-list-input',
+ fieldApiId: 'tags',
+ tagName: 'input',
+ }) as HTMLInputElement;
+
+ const result = await updater.updateField({
+ entryId: 'entry-list-input',
+ fieldApiId: 'tags',
+ fieldType: 'STRING',
+ isList: true,
+ newValue: ['a', 'b'],
+ });
+
+ expect(result.success).toBe(true);
+ expect(element.value).toBe('a, b');
+ });
+
+ it('renders one item per element when items are tagged with their list index', async () => {
+ const first = createPreviewElement({
+ entryId: 'entry-list-items',
+ fieldApiId: 'tags',
+ listIndex: 0,
+ tagName: 'li',
+ textContent: 'Original',
+ });
+ const second = createPreviewElement({
+ entryId: 'entry-list-items',
+ fieldApiId: 'tags',
+ listIndex: 1,
+ tagName: 'li',
+ textContent: 'Original',
+ });
+
+ const result = await updater.updateField({
+ entryId: 'entry-list-items',
+ fieldApiId: 'tags',
+ fieldType: 'STRING',
+ isList: true,
+ newValue: ['one', 'two'],
+ });
+
+ expect(result.success).toBe(true);
+ expect(first.textContent).toBe('one');
+ expect(second.textContent).toBe('two');
+ });
+
+ it('empties a tagged item whose index is no longer in the list', async () => {
+ const element = createPreviewElement({
+ entryId: 'entry-list-shrunk',
+ fieldApiId: 'tags',
+ listIndex: 1,
+ tagName: 'li',
+ textContent: 'two',
+ });
+
+ const result = await updater.updateField({
+ entryId: 'entry-list-shrunk',
+ fieldApiId: 'tags',
+ fieldType: 'STRING',
+ isList: true,
+ newValue: ['one'],
+ });
+
+ expect(result.success).toBe(true);
+ expect(element.textContent).toBe('');
+ });
+
+ it('refuses to overwrite a list container that has child elements', async () => {
+ const container = createPreviewElement({
+ entryId: 'entry-list-container',
+ fieldApiId: 'tags',
+ tagName: 'ul',
+ });
+ container.innerHTML = '
- a
- b
';
+
+ const result = await updater.updateField({
+ entryId: 'entry-list-container',
+ fieldApiId: 'tags',
+ fieldType: 'STRING',
+ isList: true,
+ newValue: ['a', 'b', 'c'],
+ });
+
+ expect(result.success).toBe(false);
+ expect(container.querySelectorAll('li')).toHaveLength(2);
+ });
+
+ it('renders a date list as its raw values instead of formatting the array as one date', async () => {
+ const element = createPreviewElement({
+ entryId: 'entry-list-dates',
+ fieldApiId: 'availableOn',
+ });
+
+ const result = await updater.updateField({
+ entryId: 'entry-list-dates',
+ fieldApiId: 'availableOn',
+ fieldType: 'DATE',
+ isList: true,
+ newValue: ['2026-01-01', '2026-02-02'],
+ });
+
+ expect(result.success).toBe(true);
+ expect(element.textContent).toBe('2026-01-01, 2026-02-02');
+ });
+
+ it('renders an empty list as an empty binding', async () => {
+ const element = createPreviewElement({
+ entryId: 'entry-list-empty',
+ fieldApiId: 'tags',
+ textContent: 'Original',
+ });
+
+ const result = await updater.updateField({
+ entryId: 'entry-list-empty',
+ fieldApiId: 'tags',
+ fieldType: 'STRING',
+ isList: true,
+ newValue: [],
+ });
+
+ expect(result.success).toBe(true);
+ expect(element.textContent).toBe('');
+ });
+ });
+
+});
diff --git a/src/core/ContentUpdater.ts b/src/core/ContentUpdater.ts
index adcfb59..5bc019b 100644
--- a/src/core/ContentUpdater.ts
+++ b/src/core/ContentUpdater.ts
@@ -56,7 +56,9 @@ export class ContentUpdater {
// Check if this update is still the latest
const latestUpdate = this.updateQueue.get(updateKey);
if (latestUpdate !== update) {
- return { success: true }; // Superseded by newer update
+ // Superseded by a newer update, which will apply itself. Reported separately from a real
+ // update so callers do not announce a change that never reached the DOM.
+ return { success: true, superseded: true };
}
// Remove from queue
@@ -197,6 +199,13 @@ export class ContentUpdater {
}
private async updateElement(element: HTMLElement, update: FieldUpdate): Promise {
+ // Scalar lists render as one joined string per binding, or per item when items are tagged with
+ // their index; consumers wanting anything else get the raw array from onFieldUpdate / the event.
+ if (update.isList) {
+ this.updateListField(element, update.newValue);
+ return;
+ }
+
switch (update.fieldType) {
case 'STRING':
case 'ID':
@@ -266,17 +275,50 @@ export class ContentUpdater {
}
}
- private updateTextField(element: HTMLElement, newValue: string): void {
- if (!newValue && newValue !== '') return;
+ private updateListField(element: HTMLElement, values: unknown): void {
+ const items = Array.isArray(values) ? values : [values];
+ const renderItem = (item: unknown) => (item === null || item === undefined ? '' : String(item));
+
+ // Per-item binding: the element renders exactly one item of the list.
+ const listIndex = element.getAttribute('data-hygraph-list-index');
+ if (listIndex !== null) {
+ const index = Number(listIndex);
+ this.setTextValue(element, Number.isInteger(index) ? renderItem(items[index]) : '');
+ return;
+ }
+
+ // Whole-list binding: joining is best effort, and writing into a container (a of - items,
+ // say) would delete children the SDK cannot rebuild - on this update or any later one. Refuse
+ // instead, so the failure surfaces via preview:update-failed and the markup survives.
+ if (!this.isTextValueElement(element) && element.children.length > 0) {
+ throw new Error(
+ `Cannot render list field "${element.getAttribute('data-hygraph-field-api-id')}" into <${element.tagName.toLowerCase()}>: ` +
+ 'it has child elements that would be destroyed. Tag each item with data-hygraph-list-index, ' +
+ 'or render the list yourself via onFieldUpdate / the preview:field-updated event.'
+ );
+ }
- // Update text content
- if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
- (element as HTMLInputElement).value = newValue;
+ this.setTextValue(element, items.map(renderItem).join(', '));
+ }
+
+ private isTextValueElement(element: HTMLElement): boolean {
+ return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA';
+ }
+
+ private setTextValue(element: HTMLElement, value: string): void {
+ if (this.isTextValueElement(element)) {
+ (element as HTMLInputElement).value = value;
} else {
- element.textContent = newValue;
+ element.textContent = value;
}
}
+ private updateTextField(element: HTMLElement, newValue: string): void {
+ if (!newValue && newValue !== '') return;
+
+ this.setTextValue(element, newValue);
+ }
+
private async updateRichTextField(element: HTMLElement, richTextData: RichTextAST | string | RichTextFormats): Promise {
try {
let content: string;
diff --git a/src/core/Preview.test.ts b/src/core/Preview.test.ts
index 09acb25..4f6c6e6 100644
--- a/src/core/Preview.test.ts
+++ b/src/core/Preview.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Preview } from './Preview';
-import { createMockDOM, createPreviewElement, createMockMessageBridge, waitFor } from '../test-utils';
+import { createMockDOM, createPreviewElement, createMockMessageBridge, findPostedMessage, waitFor } from '../test-utils';
+import type { FieldUpdate, PreviewConfig, StudioMessage } from '../types';
const endpoint = 'https://example.com/graphql';
const allowedOrigin = 'https://app.hygraph.com';
@@ -8,6 +9,7 @@ const allowedOrigin = 'https://app.hygraph.com';
describe('Preview', () => {
let preview: Preview | null;
let bridgeMock: ReturnType;
+ const cleanups: Array<() => void> = [];
beforeEach(() => {
createMockDOM();
@@ -16,6 +18,7 @@ describe('Preview', () => {
});
afterEach(() => {
+ cleanups.splice(0).forEach((cleanup) => cleanup());
preview?.destroy();
bridgeMock.restore();
vi.restoreAllMocks();
@@ -111,5 +114,142 @@ describe('Preview', () => {
preview.destroy();
expect((window as typeof window & { __HYGRAPH_PREVIEW__?: Preview }).__HYGRAPH_PREVIEW__).toBeUndefined();
});
-});
+ describe('scalar list updates', () => {
+ /** Collect the details of every event of this type until the test ends */
+ const captureEvent = (type: string) => {
+ const details: unknown[] = [];
+ const listener = ((event: Event) => details.push((event as CustomEvent).detail)) as EventListener;
+
+ document.addEventListener(type, listener);
+ cleanups.push(() => document.removeEventListener(type, listener));
+
+ return details;
+ };
+
+ const connect = (config: Partial = {}) => {
+ const instance = new Preview({
+ endpoint,
+ mode: 'iframe',
+ allowedOrigins: [allowedOrigin],
+ updateDelay: 0,
+ ...config,
+ });
+
+ bridgeMock.dispatch({ type: 'init', studioOrigin: allowedOrigin, timestamp: Date.now() }, allowedOrigin);
+
+ return instance;
+ };
+
+ const sendFieldUpdate = (update: Partial = {}) =>
+ bridgeMock.dispatch(
+ {
+ type: 'field-update',
+ entryId: 'entry-list-sync',
+ fieldApiId: 'tags',
+ fieldType: 'STRING',
+ newValue: 'hello',
+ timestamp: Date.now(),
+ ...update,
+ } as StudioMessage,
+ allowedOrigin
+ );
+
+ // Studio withholds list updates from an SDK that does not announce this, because an SDK without
+ // list support writes the array straight into the bound element and destroys container markup
+ it('announces scalarListSync in the ready message', async () => {
+ preview = connect();
+
+ await waitFor(() => {
+ expect(findPostedMessage(bridgeMock, 'ready')?.capabilities?.scalarListSync).toBe(true);
+ });
+ });
+
+ it('announces scalarListSync regardless of the sync options the consumer set', async () => {
+ preview = connect({ sync: { fieldUpdate: false, fieldFocus: false } });
+
+ await waitFor(() => {
+ const capabilities = findPostedMessage(bridgeMock, 'ready')?.capabilities;
+ expect(capabilities).toMatchObject({ scalarListSync: true, fieldUpdateSync: false, fieldFocusSync: false });
+ });
+ });
+
+ it('applies a scalar list update received from Studio', async () => {
+ const element = createPreviewElement({
+ entryId: 'entry-list-sync',
+ fieldApiId: 'tags',
+ textContent: 'Original',
+ });
+
+ preview = connect();
+ sendFieldUpdate({ isList: true, newValue: ['a', 'b'] } as Partial);
+
+ await waitFor(() => {
+ expect(element.textContent).toBe('a, b');
+ });
+ });
+
+ it('hands a scalar list to a custom onFieldUpdate handler untouched', async () => {
+ const received: FieldUpdate[] = [];
+
+ preview = connect({ onFieldUpdate: (update) => received.push(update) });
+ sendFieldUpdate({
+ isList: true,
+ newValue: ['a', 'b'],
+ componentChain: [{ fieldApiId: 'sections', instanceId: 'inst-1' }],
+ } as Partial);
+
+ await waitFor(() => {
+ expect(received).toHaveLength(1);
+ });
+ expect(received[0].isList).toBe(true);
+ expect(received[0].newValue).toEqual(['a', 'b']);
+ expect(received[0].componentChain).toEqual([{ fieldApiId: 'sections', instanceId: 'inst-1' }]);
+ });
+
+ it('emits preview:field-updated once the built-in updater applied the change', async () => {
+ createPreviewElement({ entryId: 'entry-list-sync', fieldApiId: 'tags', textContent: 'Original' });
+
+ preview = connect();
+ const updated = captureEvent('preview:field-updated');
+ sendFieldUpdate({ isList: true, newValue: ['a'] } as Partial);
+
+ await waitFor(() => {
+ expect(updated).toHaveLength(1);
+ });
+ expect(updated[0]).toMatchObject({ entryId: 'entry-list-sync', fieldApiId: 'tags', isList: true });
+ });
+
+ // Two updates for the same field inside the debounce window leave only the second one to reach
+ // the DOM. The first must stay silent: telling listeners a change was applied when it was
+ // discarded makes them re-render from a value the page never received.
+ it('emits preview:field-updated once when an update is superseded while debouncing', async () => {
+ createPreviewElement({ entryId: 'entry-list-sync', fieldApiId: 'tags', textContent: 'Original' });
+
+ preview = connect({ updateDelay: 30 });
+ const updated = captureEvent('preview:field-updated');
+
+ sendFieldUpdate({ isList: true, newValue: ['first'] } as Partial);
+ sendFieldUpdate({ isList: true, newValue: ['second'] } as Partial);
+
+ await waitFor(() => {
+ expect(document.querySelector('[data-hygraph-field-api-id="tags"]')!.textContent).toBe('second');
+ });
+ expect(updated).toHaveLength(1);
+ });
+
+ it('emits preview:update-failed instead when no element on the page carries the field', async () => {
+ preview = connect();
+ const updated = captureEvent('preview:field-updated');
+ const failed = captureEvent('preview:update-failed');
+ sendFieldUpdate({ isList: true, newValue: ['a'] } as Partial);
+
+ await waitFor(() => {
+ expect(failed).toHaveLength(1);
+ });
+ expect(updated).toHaveLength(0);
+ expect(failed[0]).toMatchObject({ entryId: 'entry-list-sync', fieldApiId: 'tags' });
+ });
+ });
+
+});
diff --git a/src/core/Preview.ts b/src/core/Preview.ts
index d1be133..67ee0c6 100644
--- a/src/core/Preview.ts
+++ b/src/core/Preview.ts
@@ -312,6 +312,8 @@ export class Preview {
const capabilities: SDKCapabilities = {
fieldFocusSync: this.config.sync?.fieldFocus ?? false, // Default: disabled
fieldUpdateSync: this.config.sync?.fieldUpdate ?? false, // Default: disabled
+ // Not configurable: it states what this build can handle, not what the consumer wants
+ scalarListSync: true,
richTextFormatPreferences: this.scanRichTextFormatPreferences() // Scan DOM for format preferences
};
@@ -365,11 +367,36 @@ export class Preview {
// Use built-in ContentUpdater only when no custom handler is provided
console.log('[Preview] Using built-in ContentUpdater');
console.log('[Preview] About to call contentUpdater.updateField with:', message);
- await this.contentUpdater.updateField(message);
+ const result = await this.contentUpdater.updateField(message);
- // Note: Field updates happen automatically via ContentUpdater
- // No user-facing events emitted as this is internal behavior
+ // A newer update for the same field replaced this one mid-debounce; that one reports for itself
+ if (result.superseded) {
+ return;
+ }
+
+ // The update did not reach the DOM (no tagged element, unsupported value shape, ...), so report
+ // the failure rather than telling listeners the preview is in sync with the editor.
+ if (!result.success) {
+ this.emitEvent('preview:update-failed', {
+ entryId: message.entryId,
+ fieldApiId: message.fieldApiId,
+ error: result.error || 'Unknown error',
+ });
+ return;
+ }
}
+
+ // Let listeners react to the change themselves - a component list, say, whose markup the
+ // built-in updater deliberately leaves alone
+ this.emitEvent('preview:field-updated', {
+ entryId: message.entryId,
+ fieldApiId: message.fieldApiId,
+ newValue: message.newValue,
+ transformedValue: message.fieldType === 'COMPONENT_ARRAY' ? message.transformedValue : undefined,
+ componentChain: message.componentChain,
+ isList: message.isList,
+ fieldType: message.fieldType,
+ });
}
private handleFieldFocus(message: StudioMessage & { type: 'field-focus' }): void {
diff --git a/src/core/attributes.ts b/src/core/attributes.ts
index f2f0bf0..637b687 100644
--- a/src/core/attributes.ts
+++ b/src/core/attributes.ts
@@ -4,10 +4,16 @@ export interface PreviewFieldOptions {
entryId: string;
fieldApiId?: string;
componentChain?: ComponentChainLink[];
+ /**
+ * Index of the scalar list item this element renders. Tag one node per item with its index to
+ * have list updates patch each item individually; without it an element is treated as a binding
+ * for the whole list and receives every item joined into a single string.
+ */
+ listIndex?: number;
}
export function createPreviewAttributes(options: PreviewFieldOptions): ElementAttributes {
- const { entryId, fieldApiId, componentChain } = options;
+ const { entryId, fieldApiId, componentChain, listIndex } = options;
if (!entryId) {
throw new Error('[Preview SDK] createPreviewAttributes requires an entryId');
@@ -26,6 +32,13 @@ export function createPreviewAttributes(options: PreviewFieldOptions): ElementAt
attributes['data-hygraph-component-chain'] = serializedChain;
}
+ if (listIndex !== undefined) {
+ if (!Number.isInteger(listIndex) || listIndex < 0) {
+ throw new Error('[Preview SDK] createPreviewAttributes requires listIndex to be a non-negative integer');
+ }
+ attributes['data-hygraph-list-index'] = String(listIndex);
+ }
+
return attributes;
}
diff --git a/src/types/index.ts b/src/types/index.ts
index a3e25f7..1e7571e 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -29,6 +29,15 @@ export type StudioMessage =
export interface SDKCapabilities {
fieldFocusSync?: boolean; // Whether SDK wants field focus sync messages
fieldUpdateSync?: boolean; // Whether SDK wants field update sync messages
+ /**
+ * Whether this SDK understands scalar list updates (`isList`).
+ *
+ * Studio must withhold them when this is absent. An SDK without list support writes the array
+ * straight into the bound element, which destroys the children of a container binding (a `
` of
+ * `- ` items) and renders "Invalid Date" for a Date list - and list fields sent no updates at all
+ * before this capability existed, so withholding them leaves such a page exactly as it was.
+ */
+ scalarListSync?: boolean;
richTextFormatPreferences?: { [fieldKey: string]: RichTextFormatType }; // Format preferences per field (one format per field)
}
@@ -91,13 +100,22 @@ interface FieldUpdateBase {
fieldApiId: string;
componentChain?: ComponentChainLink[];
updateId?: string;
+ /** True when newValue is a scalar list (String/Int/Float/Boolean/Enumeration/Date list) */
+ isList?: boolean;
}
type TextFieldType = 'STRING' | 'ID' | 'ENUMERATION';
type NumberFieldType = 'INT' | 'FLOAT';
type DateFieldType = 'DATETIME' | 'DATE';
+/** Field types that can be declared a list in the schema, and so arrive with `isList: true` */
+type ScalarListFieldType = TextFieldType | NumberFieldType | DateFieldType | 'BOOLEAN';
+
+/** Every scalar list arrives as a flat array of primitives, whatever the field type */
+export type ScalarListValue = Array;
+
export type FieldUpdate =
+ | (FieldUpdateBase & { fieldType: ScalarListFieldType; isList: true; newValue: ScalarListValue })
| (FieldUpdateBase & { fieldType: TextFieldType; newValue: string })
| (FieldUpdateBase & { fieldType: 'RICHTEXT'; newValue: RichTextUpdateValue })
| (FieldUpdateBase & { fieldType: NumberFieldType; newValue: number })
@@ -277,6 +295,8 @@ export interface ElementAttributes {
'data-hygraph-entry-id': string;
'data-hygraph-field-api-id'?: string;
'data-hygraph-component-chain'?: string;
+ /** Index of a single item of a scalar list field, when the element renders one item */
+ 'data-hygraph-list-index'?: string;
}
// Registered element information
@@ -308,7 +328,15 @@ export interface PreviewEvents {
mode?: 'iframe' | 'standalone';
}>;
'preview:content-saved': CustomEvent<{ entryId: string; timestamp: number }>;
- 'preview:field-updated': CustomEvent<{ entryId: string; fieldApiId: string; newValue: FieldUpdateValue }>;
+ 'preview:field-updated': CustomEvent<{
+ entryId: string;
+ fieldApiId: string;
+ newValue: FieldUpdateValue;
+ transformedValue?: ComponentData[];
+ componentChain?: ComponentChainLink[];
+ isList?: boolean;
+ fieldType?: FieldType;
+ }>;
'preview:update-failed': CustomEvent<{ entryId: string; fieldApiId: string; error: string }>;
}
@@ -339,6 +367,8 @@ export interface UpdateResult {
success: boolean;
error?: string;
element?: HTMLElement;
+ /** True when a newer update for the same field replaced this one while it was being debounced */
+ superseded?: boolean;
}
// ========== BROWSER GLOBALS ==========