Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ul>` of `<li>` 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
149 changes: 148 additions & 1 deletion src/core/ContentUpdater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<li>a</li><li>b</li>';

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('');
});
});

});
56 changes: 49 additions & 7 deletions src/core/ContentUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,6 +199,13 @@ export class ContentUpdater {
}

private async updateElement(element: HTMLElement, update: FieldUpdate): Promise<void> {
// 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':
Expand Down Expand Up @@ -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 <ul> of <li> 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<void> {
try {
let content: string;
Expand Down
Loading
Loading