Skip to content

Commit c7417f1

Browse files
authored
feat[RSC]: [ENG-8936] Add LiveEdit support for client-side updates using BuilderContext.Provider (BuilderIO#4022)
**Description:** This PR introduces client-side live editing support by extending the existing context and rendering logic in the SDK. **What changes were made:** - Added a new `BuilderContext.Provider` inside `EnableEditor` to consume a derived state from the `builderContextSignal` prop in `Content`. - Introduced a `LiveEdit` wrapper component that: - Finds a block by ID from the current context content. - Applies updated component options dynamically to render changes in real time. - Updated `InteractiveElement` to conditionally render `LiveEdit` when in editing mode. - Implemented logic to differentiate between `editType: 'server'` and `editType: 'client'`: - For `'server'`, retain existing behavior—push updates to LRU cache and re-render on the server. - For `'client'`, trigger a context update that re-renders `LiveEdit` components on the client side. **Why these changes were made:** To support hybrid editing in the SDK where developers can opt into either server-side or client-side editing behavior, depending on the use case. **Additional context:** - These changes are foundational for enabling smoother, near-instant updates during visual editing sessions—especially when using `editType: 'client'`. - These changes will also require handling on `builder-internal` --- ### EDIT: This PR also consists following fixes 1. Styles not updating in RSC - Issue: [Jira](https://builder-io.atlassian.net/browse/ENG-9177), [Loom](https://www.loom.com/share/caa68d02e08941078dd358c886f7331f?sid=712edbae-376a-4ab1-b6f2-b9dce4580819) - Fix: [Loom](https://www.loom.com/share/1066866086504897aefccf18a7a1850d?sid=11769487-3a3f-4114-a723-16a06cd3db6c)
1 parent a491242 commit c7417f1

File tree

17 files changed

+524
-69
lines changed

17 files changed

+524
-69
lines changed

.changeset/silly-moose-taste.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@builder.io/sdk": minor
3+
"@builder.io/sdk-react-nextjs": minor
4+
---
5+
6+
7+
**Refactor Next.js SDK to support RSC-based hybrid editing for \[Text, Image, Video, Button, Section, Columns, Symbols]**
8+
9+
* Client-side updates enabled for faster editing on Text, Image, Video, and Button
10+
* Section, Columns, and Symbols treated as server components for optimized SSR
11+
* Improved performance and flexibility for visual editing in RSC environments

packages/core/src/builder.class.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,11 @@ export interface Component {
773773
*/
774774
models?: readonly string[];
775775

776+
/**
777+
* Whether the component is a React Server Component
778+
*/
779+
isRSC?: boolean;
780+
776781
/**
777782
* Specify restrictions direct children must match
778783
*/

packages/sdks-tests/src/e2e-tests/editing.spec.ts

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ const editorTests = ({
3636
}) => {
3737
test('correctly updates Text block', async ({ page, basePort, packageName, sdk }) => {
3838
test.skip(
39-
packageName === 'nextjs-sdk-next-app' ||
40-
packageName === 'gen1-next14-pages' ||
39+
packageName === 'gen1-next14-pages' ||
4140
packageName === 'gen1-next15-app' ||
4241
packageName === 'gen1-remix'
4342
);
@@ -75,8 +74,7 @@ const editorTests = ({
7574

7675
test('correctly updates Text block styles', async ({ page, packageName, basePort, sdk }) => {
7776
test.skip(
78-
packageName === 'nextjs-sdk-next-app' ||
79-
packageName === 'gen1-next14-pages' ||
77+
packageName === 'gen1-next14-pages' ||
8078
packageName === 'gen1-next15-app' ||
8179
packageName === 'gen1-remix'
8280
);
@@ -97,6 +95,7 @@ const editorTests = ({
9795
sdk,
9896
path: '/data/blocks/0/responsiveStyles/large/backgroundColor',
9997
updateFn: () => 'rgb(19, 67, 92)',
98+
editType: packageName === 'nextjs-sdk-next-app' ? 'client' : undefined,
10099
});
101100

102101
const btn = page.frameLocator('iframe').getByRole(sdk === 'oldReact' ? 'button' : 'link');
@@ -113,11 +112,11 @@ test.describe('Visual Editing', () => {
113112
sdk,
114113
}) => {
115114
test.skip(
116-
packageName === 'nextjs-sdk-next-app' ||
117-
packageName === 'gen1-next14-pages' ||
118-
packageName === 'gen1-next15-app' ||
115+
packageName === 'gen1-next15-app' ||
119116
packageName === 'gen1-react' ||
120-
packageName === 'gen1-remix'
117+
packageName === 'gen1-remix' ||
118+
packageName === 'gen1-next14-pages' ||
119+
packageName === 'nextjs-sdk-next-app'
121120
);
122121

123122
await launchEmbedderAndWaitForSdk({
@@ -137,7 +136,12 @@ test.describe('Visual Editing', () => {
137136
expect(firstBox.y).toBeLessThan(secondBox.y);
138137
}
139138

140-
await sendContentUpdateMessage({ page, newContent: MODIFIED_EDITING_COLUMNS, model: 'page' });
139+
await sendContentUpdateMessage({
140+
page,
141+
newContent: MODIFIED_EDITING_COLUMNS,
142+
model: 'page',
143+
editType: packageName === 'nextjs-sdk-next-app' ? 'server' : undefined,
144+
});
141145
// had to hack this so that we can wait for the content update to actually show up (was failing in Qwik)
142146
await page.frameLocator('iframe').getByText('third').waitFor();
143147

@@ -187,7 +191,7 @@ test.describe('Visual Editing', () => {
187191
});
188192

189193
test('removal of styles should work properly', async ({ page, packageName, sdk, basePort }) => {
190-
test.skip(packageName === 'nextjs-sdk-next-app' || checkIsGen1React(sdk));
194+
test.skip(checkIsGen1React(sdk));
191195

192196
await launchEmbedderAndWaitForSdk({
193197
path: '/editing-styles',
@@ -208,6 +212,7 @@ test.describe('Visual Editing', () => {
208212
page,
209213
newContent,
210214
model: 'page',
215+
editType: packageName === 'nextjs-sdk-next-app' ? 'client' : undefined,
211216
});
212217

213218
await expect(buttonLocator).toHaveCSS('margin-top', '0px');
@@ -220,11 +225,11 @@ test.describe('Visual Editing', () => {
220225
sdk,
221226
}) => {
222227
test.skip(
223-
packageName === 'nextjs-sdk-next-app' ||
224-
packageName === 'gen1-next14-pages' ||
228+
packageName === 'gen1-next14-pages' ||
225229
packageName === 'gen1-next15-app' ||
226230
packageName === 'gen1-react' ||
227-
packageName === 'gen1-remix'
231+
packageName === 'gen1-remix' ||
232+
packageName === 'nextjs-sdk-next-app'
228233
);
229234

230235
test.skip(packageName === 'vue', 'Vue tests flake on this one for an unnkown reason.');
@@ -255,24 +260,29 @@ test.describe('Visual Editing', () => {
255260
test.describe('Column block', () => {
256261
test('correctly updates nested Text block', async ({ page, basePort, packageName, sdk }) => {
257262
test.skip(
258-
packageName === 'nextjs-sdk-next-app' ||
259-
packageName === 'gen1-next14-pages' ||
263+
packageName === 'gen1-next14-pages' ||
260264
packageName === 'gen1-next15-app' ||
261265
packageName === 'gen1-react' ||
262-
packageName === 'gen1-remix'
266+
packageName === 'gen1-remix' ||
267+
packageName === 'nextjs-sdk-next-app'
263268
);
264269

265270
await launchEmbedderAndWaitForSdk({ path: '/columns', basePort, page, sdk });
266-
await sendContentUpdateMessage({ page, newContent: COLUMNS_WITH_NEW_TEXT, model: 'page' });
271+
await sendContentUpdateMessage({
272+
page,
273+
newContent: COLUMNS_WITH_NEW_TEXT,
274+
model: 'page',
275+
editType: packageName === 'nextjs-sdk-next-app' ? 'server' : undefined,
276+
});
267277
await page.frameLocator('iframe').getByText(NEW_TEXT).waitFor();
268278
});
269279
test('correctly updates space prop', async ({ page, basePort, packageName, sdk }) => {
270280
test.skip(
271-
packageName === 'nextjs-sdk-next-app' ||
272-
packageName === 'gen1-next14-pages' ||
281+
packageName === 'gen1-next14-pages' ||
273282
packageName === 'gen1-next15-app' ||
274283
packageName === 'gen1-react' ||
275-
packageName === 'gen1-remix'
284+
packageName === 'gen1-remix' ||
285+
packageName === 'nextjs-sdk-next-app'
276286
);
277287

278288
const selector = checkIsRN(sdk)
@@ -282,18 +292,23 @@ test.describe('Visual Editing', () => {
282292
const secondColumn = page.frameLocator('iframe').locator(selector).nth(1);
283293

284294
await expect(secondColumn).toHaveCSS('margin-left', checkIsRN(sdk) ? '0px' : '20px');
285-
await sendContentUpdateMessage({ page, newContent: COLUMNS_WITH_NEW_SPACE, model: 'page' });
295+
await sendContentUpdateMessage({
296+
page,
297+
newContent: COLUMNS_WITH_NEW_SPACE,
298+
model: 'page',
299+
editType: packageName === 'nextjs-sdk-next-app' ? 'server' : undefined,
300+
});
286301
await expect(secondColumn).toHaveCSS('margin-left', '10px');
287302
});
288303
test('correctly updates width props', async ({ page, basePort, packageName, sdk }) => {
289304
test.skip(
290305
packageName === 'react-native-74' ||
291306
packageName === 'react-native-76-fabric' ||
292-
packageName === 'nextjs-sdk-next-app' ||
293307
packageName === 'gen1-next14-pages' ||
294308
packageName === 'gen1-next15-app' ||
295309
packageName === 'gen1-react' ||
296-
packageName === 'gen1-remix'
310+
packageName === 'gen1-remix' ||
311+
packageName === 'nextjs-sdk-next-app'
297312
);
298313

299314
await launchEmbedderAndWaitForSdk({ path: '/columns', basePort, page, sdk });
@@ -303,7 +318,12 @@ test.describe('Visual Editing', () => {
303318
getComputedStyle(el).width.replace('px', '')
304319
);
305320

306-
await sendContentUpdateMessage({ page, newContent: COLUMNS_WITH_NEW_WIDTHS, model: 'page' });
321+
await sendContentUpdateMessage({
322+
page,
323+
newContent: COLUMNS_WITH_NEW_WIDTHS,
324+
model: 'page',
325+
editType: packageName === 'nextjs-sdk-next-app' ? 'server' : undefined,
326+
});
307327

308328
await expect
309329
.poll(
@@ -469,10 +489,10 @@ test.describe('Visual Editing', () => {
469489
test.describe('Content Input', () => {
470490
test('correctly updates', async ({ page, packageName, basePort, sdk }) => {
471491
test.skip(
472-
packageName === 'nextjs-sdk-next-app' ||
473-
packageName === 'gen1-next14-pages' ||
492+
packageName === 'gen1-next14-pages' ||
474493
packageName === 'gen1-next15-app' ||
475-
packageName === 'gen1-remix'
494+
packageName === 'gen1-remix' ||
495+
packageName === 'nextjs-sdk-next-app'
476496
);
477497

478498
await launchEmbedderAndWaitForSdk({ path: '/content-input-bindings', basePort, page, sdk });
@@ -484,6 +504,7 @@ test.describe('Visual Editing', () => {
484504
booleanToggle: true,
485505
},
486506
model: 'page',
507+
editType: packageName === 'nextjs-sdk-next-app' ? 'client' : undefined,
487508
});
488509
await page.frameLocator('iframe').getByText('Hello').waitFor();
489510

@@ -493,6 +514,7 @@ test.describe('Visual Editing', () => {
493514
booleanToggle: false,
494515
},
495516
model: 'page',
517+
editType: packageName === 'nextjs-sdk-next-app' ? 'client' : undefined,
496518
});
497519
await page.frameLocator('iframe').getByText('Bye').waitFor();
498520
});
@@ -506,7 +528,9 @@ test.describe('Visual Editing', () => {
506528
packageName,
507529
}) => {
508530
test.skip(checkIsGen1React(sdk));
509-
test.skip(packageName === 'nextjs-sdk-next-app');
531+
test.skip(
532+
packageName === 'nextjs-sdk-next-app'
533+
);
510534
test.skip(
511535
packageName === 'vue',
512536
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`
@@ -529,7 +553,9 @@ test.describe('Visual Editing', () => {
529553
page,
530554
newContent,
531555
model: 'page',
556+
editType: packageName === 'nextjs-sdk-next-app' ? 'client' : undefined,
532557
});
558+
533559
await page.frameLocator('iframe').getByText('new text').waitFor();
534560

535561
const textBlocks = await page
@@ -551,7 +577,9 @@ test.describe('Visual Editing', () => {
551577

552578
test('should add new block in the middle', async ({ page, basePort, sdk, packageName }) => {
553579
test.skip(checkIsGen1React(sdk));
554-
test.skip(packageName === 'nextjs-sdk-next-app');
580+
test.skip(
581+
packageName === 'nextjs-sdk-next-app'
582+
);
555583
test.skip(
556584
packageName === 'qwik-city' || packageName === 'nuxt',
557585
'Failing on the CI: Test timeout of 30000ms exceeded'
@@ -603,7 +631,9 @@ test.describe('Visual Editing', () => {
603631

604632
test('should add new block at the top', async ({ page, basePort, sdk, packageName }) => {
605633
test.skip(checkIsGen1React(sdk));
606-
test.skip(packageName === 'nextjs-sdk-next-app');
634+
test.skip(
635+
packageName === 'nextjs-sdk-next-app'
636+
);
607637
test.skip(
608638
packageName === 'vue',
609639
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`
@@ -629,7 +659,7 @@ test.describe('Visual Editing', () => {
629659
});
630660
await page.frameLocator('iframe').getByText('add to top').waitFor();
631661

632-
const textBlocks = await page
662+
const textBlocks = await page
633663
.frameLocator('iframe')
634664
.getByText('some text already published')
635665
.all();
@@ -656,7 +686,9 @@ test.describe('Visual Editing', () => {
656686
packageName,
657687
}) => {
658688
test.skip(checkIsGen1React(sdk));
659-
test.skip(packageName === 'nextjs-sdk-next-app');
689+
test.skip(
690+
packageName === 'nextjs-sdk-next-app'
691+
);
660692
test.skip(
661693
packageName === 'vue',
662694
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`
@@ -700,12 +732,18 @@ test.describe('Visual Editing', () => {
700732
sdk === 'qwik',
701733
'Qwik fails to update the data when nested values are updated. Need to raise another PR.'
702734
);
735+
test.skip(
736+
packageName === 'nextjs-sdk-next-app'
737+
);
703738
// Loom for reference: https://www.loom.com/share/b951939394ca4758b4a362725016d30b?sid=c54d90f5-121a-4652-877e-5abb6ddd2605
704739
test.skip(
705740
sdk === 'vue',
706741
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`
707742
);
708-
test.skip(excludeGen1(sdk) || packageName === 'nextjs-sdk-next-app');
743+
test.skip(
744+
packageName === 'nextjs-sdk-next-app'
745+
);
746+
test.skip(excludeGen1(sdk));
709747

710748
await launchEmbedderAndWaitForSdk({
711749
path: '/symbols-with-list-content-input',
@@ -736,11 +774,16 @@ test.describe('Visual Editing', () => {
736774
packageName,
737775
}) => {
738776
test.skip(checkIsGen1React(sdk));
739-
test.skip(packageName === 'nextjs-sdk-next-app');
777+
test.skip(
778+
packageName === 'nextjs-sdk-next-app'
779+
);
740780
test.skip(
741781
packageName === 'vue',
742782
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`
743783
);
784+
test.skip(
785+
packageName === 'nextjs-sdk-next-app'
786+
);
744787

745788
await launchEmbedderAndWaitForSdk({ path: '/section-children', basePort, page, sdk });
746789

@@ -778,7 +821,9 @@ test.describe('Visual Editing', () => {
778821

779822
test('should add new block in the middle', async ({ page, basePort, sdk, packageName }) => {
780823
test.skip(checkIsGen1React(sdk));
781-
test.skip(packageName === 'nextjs-sdk-next-app');
824+
test.skip(
825+
packageName === 'nextjs-sdk-next-app'
826+
);
782827
test.skip(
783828
packageName === 'vue',
784829
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`
@@ -823,7 +868,9 @@ test.describe('Visual Editing', () => {
823868

824869
test('should add new block at the top', async ({ page, basePort, sdk, packageName }) => {
825870
test.skip(checkIsGen1React(sdk));
826-
test.skip(packageName === 'nextjs-sdk-next-app');
871+
test.skip(
872+
packageName === 'nextjs-sdk-next-app'
873+
);
827874
test.skip(
828875
packageName === 'vue',
829876
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`
@@ -875,7 +922,9 @@ test.describe('Visual Editing', () => {
875922
packageName,
876923
}) => {
877924
test.skip(checkIsGen1React(sdk));
878-
test.skip(packageName === 'nextjs-sdk-next-app');
925+
test.skip(
926+
packageName === 'nextjs-sdk-next-app'
927+
);
879928
test.skip(
880929
packageName === 'vue',
881930
`Failing on the CI: TypeError: Cannot read properties of null (reading 'namespaceURI')`

packages/sdks-tests/src/e2e-tests/large-reactive-state.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ test.describe('Large Reactive State', () => {
8181
basePort,
8282
packageName,
8383
}) => {
84-
test.fail(excludeTestFor({ rsc: true }, sdk));
8584
test.skip(
8685
packageName === 'gen1-next14-pages' ||
8786
packageName === 'gen1-next15-app' ||

0 commit comments

Comments
 (0)