Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/shaggy-moles-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do a dev release first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sanyamkamat can we do the dev release?

'@builder.io/react': patch
---

Fix: Custom Code blocks with `scriptsClientOnly` now re-insert and run their `<script>` tags after hydration. Two things blocked it: the first client render only matched the script-stripped server render when the SSR'd node happened to be captured at module-evaluation time (not guaranteed with async chunk loading, e.g. Next.js), and `shouldComponentUpdate` only compared `props.code`, suppressing the one-time re-render scheduled by `componentDidMount`.
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@builder.io/react",
"version": "9.4.6",
"version": "9.4.7-0",
"description": "",
"keywords": [],
"main": "dist/builder-react.cjs.js",
Expand Down
19 changes: 14 additions & 5 deletions packages/react/src/blocks/CustomCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ interface Props {
scriptsClientOnly?: boolean;
}

interface State {
hydrated: boolean;
}

// TODO: settings context to pass this down. do in shopify-specific generated code
const globalReplaceNodes = ({} as { [key: string]: Node[] }) || null;

Expand Down Expand Up @@ -48,7 +52,7 @@ if (Builder.isBrowser && globalReplaceNodes) {
}
}

class CustomCodeComponent extends React.Component<Props> {
class CustomCodeComponent extends React.Component<Props, State> {
elementRef: Element | null = null;
originalRef: Node | Element | null = null;

Expand All @@ -57,7 +61,7 @@ class CustomCodeComponent extends React.Component<Props> {

firstLoad = true;
replaceNodes = false;
state = {
state: State = {
hydrated: false,
};

Expand Down Expand Up @@ -90,8 +94,9 @@ class CustomCodeComponent extends React.Component<Props> {
}
}

shouldComponentUpdate(nextProps: Readonly<Props>): boolean {
return nextProps.code !== this.props.code;
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: State): boolean {
// `hydrated` flips once, in componentDidMount, to re-render the scripts stripped for SSR parity.
return nextProps.code !== this.props.code || nextState.hydrated !== this.state.hydrated;
}

get noReactRender() {
Expand All @@ -100,7 +105,11 @@ class CustomCodeComponent extends React.Component<Props> {
}

get isHydrating() {
return !isShopify && this.originalRef;
// `originalRef` is only captured when the SSR'd markup was already in the document when this
// module was evaluated. Bundlers load the SDK chunk asynchronously, so that ordering is not
// guaranteed. `scriptsClientOnly` always strips scripts on the server, so the first client
// render has to match that regardless of whether the node was captured.
return !isShopify && Boolean(this.originalRef || this.props.scriptsClientOnly);
}

componentDidUpdate(prevProps: Props) {
Expand Down
157 changes: 157 additions & 0 deletions packages/react/src/blocks/custom-code.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* @jest-environment jsdom
*/

import * as React from 'react';
import { renderToString } from 'react-dom/server';
import { act, render } from '@testing-library/react';
import type { BuilderElement } from '@builder.io/sdk';
import type { CustomCode as CustomCodeComponent } from './CustomCode';

declare global {
interface Window {
__ccRuns?: number;
}
}

// jsdom does not implement innerText, which is what CustomCode reads to eval inline scripts.
if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText')) {
Object.defineProperty(HTMLElement.prototype, 'innerText', {
configurable: true,
get(this: HTMLElement) {
return this.textContent;
},
});
}

const BLOCK_ID = 'builder-custom-code-hydration';
const REMOTE_SCRIPT_SRC = 'https://example.com/custom-code.js';

const builderBlock: BuilderElement = {
'@type': '@builder.io/sdk:Element',
id: BLOCK_ID,
};

const CODE = [
'<div class="cc-body">hello</div>',
'<script>window.__ccRuns = (window.__ccRuns || 0) + 1;</script>',
`<script src="${REMOTE_SCRIPT_SRC}"></script>`,
].join('');

/**
* CustomCode collects the SSR'd nodes at module scope, so it has to be loaded after the markup
* it is meant to hydrate is already in the document.
*/
function loadCustomCode() {
jest.resetModules();
const sdk = require('@builder.io/sdk') as typeof import('@builder.io/sdk');
const blockModule = require('./CustomCode') as {
CustomCode: typeof CustomCodeComponent;
};
return { Builder: sdk.Builder, CustomCode: blockModule.CustomCode };
}

function customCodeTree(CustomCode: typeof CustomCodeComponent) {
return (
<div builder-id={BLOCK_ID} className={BLOCK_ID}>
<CustomCode code={CODE} scriptsClientOnly builderBlock={builderBlock} />
</div>
);
}

function renderSsrMarkup() {
const { Builder, CustomCode } = loadCustomCode();
Builder.isServer = true;
try {
return { markup: renderToString(customCodeTree(CustomCode)), CustomCode };
} finally {
Builder.isServer = false;
}
}

function mountSsrMarkup(markup: string): HTMLElement {
const container = document.createElement('div');
container.innerHTML = markup;
document.body.appendChild(container);
return container;
}

async function flushNextTick() {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
}

describe('CustomCode scriptsClientOnly hydration', () => {
beforeEach(() => {
delete window.__ccRuns;
document.body.innerHTML = '';
document.head.querySelectorAll(`script[src="${REMOTE_SCRIPT_SRC}"]`).forEach(el => el.remove());
});

it('strips scripts from the server render', () => {
const { markup } = renderSsrMarkup();
expect(markup).toContain('cc-body');
expect(markup).not.toContain('__ccRuns');
expect(markup).not.toContain(REMOTE_SCRIPT_SRC);
});

it('restores and runs the stripped scripts after hydration', async () => {
const { markup } = renderSsrMarkup();
const container = mountSsrMarkup(markup);

const { CustomCode } = loadCustomCode();
render(customCodeTree(CustomCode), { container, hydrate: true });
await flushNextTick();

const customCodeEl = container.querySelector('.builder-custom-code');
expect(customCodeEl).toBeTruthy();

// The re-render triggered by `hydrated` must put the stripped <script> tags back in the DOM.
expect(customCodeEl!.querySelector('script')).toBeTruthy();
// ...and findAndRunScripts must then pick them up.
expect(window.__ccRuns).toBe(1);
expect(document.head.querySelector(`script[src="${REMOTE_SCRIPT_SRC}"]`)).toBeTruthy();
});

// Bundlers load the SDK chunk asynchronously, so the module-scope `.builder-custom-code` scan
// can run before the SSR'd markup is in the document. `originalRef` then stays null and the
// block must still recover, which is the reported Next.js App Router failure.
it('restores and runs the scripts when the module loads before the SSR markup exists', async () => {
const { markup, CustomCode } = renderSsrMarkup();
const container = mountSsrMarkup(markup);

render(customCodeTree(CustomCode), { container, hydrate: true });
await flushNextTick();

const customCodeEl = container.querySelector('.builder-custom-code');
expect(customCodeEl!.querySelector('script')).toBeTruthy();
expect(window.__ccRuns).toBe(1);
expect(document.head.querySelector(`script[src="${REMOTE_SCRIPT_SRC}"]`)).toBeTruthy();
});

it('still skips re-renders when neither code nor hydration state change', async () => {
const { CustomCode } = loadCustomCode();
const renderSpy = jest.spyOn(CustomCode.prototype, 'render');

function Parent({ label }: { label: string }) {
return (
<div>
<span>{label}</span>
<CustomCode code={CODE} scriptsClientOnly builderBlock={builderBlock} />
</div>
);
}

const { rerender } = render(<Parent label="a" />);
await flushNextTick();

const rendersAfterMount = renderSpy.mock.calls.length;

rerender(<Parent label="b" />);
await flushNextTick();

expect(renderSpy.mock.calls.length).toBe(rendersAfterMount);
renderSpy.mockRestore();
});
});
8 changes: 8 additions & 0 deletions packages/react/test/setupTests.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this needed?

Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { TextDecoder, TextEncoder } from 'util';

// jsdom 19 ships without TextEncoder/TextDecoder, which react-dom/server needs on modern Node.
if (typeof globalThis.TextEncoder === 'undefined') {
globalThis.TextEncoder = TextEncoder as typeof globalThis.TextEncoder;
globalThis.TextDecoder = TextDecoder as typeof globalThis.TextDecoder;
}

beforeEach(() => {
jest.spyOn(global.Math, 'random').mockReturnValue(0.123456789);
});
Expand Down
Loading