Skip to content

Commit

Permalink
feat[react-gen2]: support for variant containers (#3828)
Browse files Browse the repository at this point in the history
## Description

Adds support for Variant Containers on Gen2 React SDK

Jira
https://builder-io.atlassian.net/browse/ENG-7676

_Screenshot_
If relevant, add a screenshot or two of the changes you made.
  • Loading branch information
sidmohanty11 authored Feb 3, 2025
1 parent d0e96d6 commit abe5cba
Show file tree
Hide file tree
Showing 41 changed files with 1,730 additions and 140 deletions.
15 changes: 15 additions & 0 deletions .changeset/beige-grapes-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-solid': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-solid';

setClientUserAttributes({
device: 'tablet',
});
```
15 changes: 15 additions & 0 deletions .changeset/dirty-teachers-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-svelte': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-svelte';

setClientUserAttributes({
device: 'tablet',
});
```
15 changes: 15 additions & 0 deletions .changeset/giant-lobsters-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-vue': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-vue';

setClientUserAttributes({
device: 'tablet',
});
```
15 changes: 15 additions & 0 deletions .changeset/itchy-beers-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-angular': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-angular';

setClientUserAttributes({
device: 'tablet',
});
```
15 changes: 15 additions & 0 deletions .changeset/lazy-waves-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-react-native': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-react-native';

setClientUserAttributes({
device: 'tablet',
});
```
15 changes: 15 additions & 0 deletions .changeset/lemon-foxes-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-qwik': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-qwik';

setClientUserAttributes({
device: 'tablet',
});
```
15 changes: 15 additions & 0 deletions .changeset/nasty-ghosts-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-react': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-react';

setClientUserAttributes({
device: 'tablet',
});
```
15 changes: 15 additions & 0 deletions .changeset/tidy-lions-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@builder.io/sdk-react-nextjs': patch
---

Feat: exports `setClientUserAttributes` helper that can be used to set and update Builder's user attributes cookie. This cookie is used by Builder's Personalization Containers to decide which variant to render.

Usage example:

```ts
import { setClientUserAttributes } from '@builder.io/sdk-react-nextjs';

setClientUserAttributes({
device: 'tablet',
});
```
5 changes: 5 additions & 0 deletions .changeset/tricky-hairs-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@builder.io/react': patch
---

Fix: hydration mismatch error and reactivity of Personalization Containers when `userAttributes` cookie value is updated.
5 changes: 5 additions & 0 deletions .changeset/wise-singers-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@builder.io/sdk-react': patch
---

Feat: support of Variant Containers or Block level personalization
7 changes: 7 additions & 0 deletions packages/react-tests/next14-pages/next.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');

/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
externalDir: true,
},
webpack: config => {
config.resolve.alias['react'] = path.resolve(__dirname, './node_modules/react');
return config;
},
};

module.exports = nextConfig;
3 changes: 3 additions & 0 deletions packages/react-tests/next14-pages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"eslint-config-next": "14.0.3",
"typescript": "^5"
},
"installConfig": {
"hoistingLimits": "workspaces"
},
"nx": {
"targets": {
"build": {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-tests/next15-app/src/components/builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ if (typeof window !== 'undefined') {
) {
builder.canTrack = false;
}

if (window.location.pathname.includes('variant-containers')) {
builder.setUserAttributes({
device: 'tablet',
});
}
}

type BuilderPageProps = any;
Expand Down
13 changes: 8 additions & 5 deletions packages/react/src/blocks/PersonalizationContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ export function PersonalizationContainer(props: PersonalizationContainerProps) {
);
const rootRef = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(isBeingHydrated);
const [update, setUpdate] = useState(0);
const [isHydrated, setIsHydrated] = useState(false);
const [_, setUpdate] = useState(0);
const builderStore = useContext(BuilderStoreContext);

useEffect(() => {
setIsClient(true);
setIsHydrated(true);
const subscriber = builder.userAttributesChanged.subscribe(() => {
setUpdate(update + 1);
setUpdate(prev => prev + 1);
});
let unsubs = [() => subscriber.unsubscribe()];

Expand Down Expand Up @@ -156,10 +158,11 @@ export function PersonalizationContainer(props: PersonalizationContainerProps) {
}}
className={`builder-personalization-container ${
props.attributes.className
} ${isClient ? '' : 'builder-personalization-container-loading'}`}
}${isClient ? '' : ' builder-personalization-container-loading'}`}
>
{/* If editing a specific varient */}
{Builder.isEditing &&
{isHydrated &&
Builder.isEditing &&
typeof props.previewingIndex === 'number' &&
props.previewingIndex < (props.variants?.length || 0) ? (
<BuilderBlocks
Expand All @@ -169,7 +172,7 @@ export function PersonalizationContainer(props: PersonalizationContainerProps) {
child
/>
) : // If editing the default or we're on the server and there are no matching variants show the default
(Builder.isEditing && typeof props.previewingIndex !== 'number') ||
(isHydrated && Builder.isEditing && typeof props.previewingIndex !== 'number') ||
!isClient ||
!filteredVariants.length ? (
<BuilderBlocks
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Browser } from '@playwright/test';
import { expect } from '@playwright/test';
import { excludeGen2, test } from '../helpers/index.js';
import { excludeGen2, isSSRFramework, test } from '../helpers/index.js';
import { launchEmbedderAndWaitForSdk } from '../helpers/visual-editor.js';
const SELECTOR = 'div[builder-content-id]';

const createContextWithCookies = async ({
Expand Down Expand Up @@ -35,19 +36,17 @@ const initializeUserAttributes = async (
page: _page,
baseURL,
browser,
sdk,
packageName,
sdk,
}: Pick<
Parameters<Parameters<typeof test>[2]>[0],
'page' | 'baseURL' | 'browser' | 'packageName' | 'sdk'
>,
{ userAttributes }: { userAttributes: Record<string, string> }
) => {
// gen1-next likely have a config issue with SSR
test.skip(packageName === 'gen1-next14-pages');
// gen1-remix started failing on this test for an unknown reason.
test.skip(packageName === 'gen1-remix');
test.skip(excludeGen2(sdk));
test.skip(excludeGen2(sdk) && sdk !== 'react');

if (!baseURL) throw new Error('Missing baseURL');

Expand Down Expand Up @@ -162,4 +161,89 @@ test.describe('Personalization Container', () => {
});
}
});

test('setClientUserAttributes and builder.setUserAttributes sets cookie and renders variant after the first render', async ({
page,
packageName,
}) => {
// here we are checking specifically for winning variant content by setting the user attributes
test.skip(!['react-sdk-next-15-app', 'gen1-next15-app'].includes(packageName));
await page.goto('/variant-containers');

// content 1
await expect(page.getByText('My tablet content')).toBeVisible();
await expect(page.getByText('My mobile content updated')).not.toBeVisible();
await expect(page.getByText('My default content')).not.toBeVisible();

// content 2 - this has no targeting set, so the first variant should be the winning variant
await expect(page.getByText('Tablet content 2')).toBeVisible();
});

test('only default variants are ssred on the server', async ({ browser, packageName, sdk }) => {
test.skip(!isSSRFramework(packageName));
test.skip(!['react', 'oldReact'].includes(sdk));
// Cannot read properties of null (reading 'useContext')
test.skip(packageName === 'gen1-remix');

const context = await browser.newContext({
javaScriptEnabled: false,
});

const page = await context.newPage();

await page.goto('/variant-containers');

await expect(page.getByText('My default content')).toBeVisible();
await expect(page.getByText('Default content 2')).toBeVisible();
});

test('root style attribute is correctly set', async ({ page, sdk, packageName }) => {
test.skip(!['react', 'oldReact'].includes(sdk));
// Cannot read properties of null (reading 'useContext')
test.skip(packageName === 'gen1-remix');

await page.goto('/variant-containers');

const secondPersonalizationContainer = page
.locator('.builder-personalization-container')
.nth(1);
await expect(secondPersonalizationContainer).toHaveCSS('background-color', 'rgb(255, 0, 0)');
});

test.describe('visual editing', () => {
test('correctly shows the variant that is being currently edited', async ({
page,
sdk,
basePort,
packageName,
}) => {
test.skip(!['react', 'oldReact'].includes(sdk));
// Cannot read properties of null (reading 'useContext')
test.skip(packageName === 'gen1-remix');

const paths = [
'/variant-containers-with-previewing-index-0',
'/variant-containers-with-previewing-index-1',
'/variant-containers-with-previewing-index-undefined',
];

const expectedTexts = [
'My tablet content',
'My mobile content updated',
'My default content',
];

for (let i = 0; i < paths.length; i++) {
const path = paths[i];
await launchEmbedderAndWaitForSdk({
path,
page,
sdk,
basePort,
});

await expect(page.frameLocator('iframe').getByText(expectedTexts[i])).toBeVisible();
}
});
});
});
18 changes: 8 additions & 10 deletions packages/sdks-tests/src/e2e-tests/slot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { checkIsRN, test } from '../helpers/index.js';

test.describe('Slot', () => {
test('slot should render', async ({ page, packageName }) => {
// gen1-remix and gen1-next skipped because React.useContext is not recognized
test.fail(['gen1-remix', 'gen1-next14-pages'].includes(packageName));
// gen1-remix skipped because React.useContext is not recognized
test.fail(['gen1-remix'].includes(packageName));
await page.goto('/slot');
await expect(page.locator('text=Inside a slot!!')).toBeVisible();
});

test('slot should render in the correct place', async ({ page, packageName, sdk }) => {
// gen1-remix and gen1-next skipped because React.useContext is not recognized
test.fail(['gen1-remix', 'gen1-next14-pages'].includes(packageName));
// gen1-remix skipped because React.useContext is not recognized
test.fail(['gen1-remix'].includes(packageName));
await page.goto('/slot');
const builderTextElements = checkIsRN(sdk)
? page.locator('[data-testid="div"]')
Expand All @@ -24,19 +24,17 @@ test.describe('Slot', () => {
});

test('slot should render with symbol (with content)', async ({ page, packageName }) => {
// gen1-remix and gen1-next skipped because React.useContext is not recognized
test.fail(['gen1-remix', 'gen1-next14-pages'].includes(packageName));
// gen1-remix skipped because React.useContext is not recognized
test.fail(['gen1-remix'].includes(packageName));
await page.goto('/slot-with-symbol');

await expect(page.locator('text=This is called recursion!')).toBeVisible();
});

test('slot should render with symbol (without content)', async ({ page, packageName, sdk }) => {
// gen1-remix and gen1-next skipped because React.useContext is not recognized
// gen1-remix skipped because React.useContext is not recognized
// ssr packages skipped because it fetches the slot content from the server
test.fail(
['gen1-remix', 'gen1-next14-pages', 'nextjs-sdk-next-app', 'qwik-city'].includes(packageName)
);
test.fail(['gen1-remix', 'nextjs-sdk-next-app', 'qwik-city'].includes(packageName));

let x = 0;

Expand Down
2 changes: 0 additions & 2 deletions packages/sdks-tests/src/e2e-tests/state-binding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ test.describe('State binding', () => {
packageName === 'angular-16-ssr' || packageName === 'angular-16',
'Angular Gen2 event binding not working properly for repeat blocks.'
);
// hydration errors
test.fail(packageName === 'gen1-next14-pages');

// flaky, can't `test.fail()`
test.skip(
Expand Down
Loading

0 comments on commit abe5cba

Please sign in to comment.