Skip to content

feat: Optimize StaticHtml component for React js 🚀 #14917

Merged
Princesseuh merged 18 commits into
withastro:mainfrom
sanjaiyan-dev:sanjaiyan-react-html-optmize
Mar 24, 2026
Merged

feat: Optimize StaticHtml component for React js 🚀 #14917
Princesseuh merged 18 commits into
withastro:mainfrom
sanjaiyan-dev:sanjaiyan-react-html-optmize

Conversation

@sanjaiyan-dev
Copy link
Copy Markdown
Contributor

This PR optimizes the StaticHtml component, addressing the limitations of the previous implementation (which relied on an incorrect shouldComponentUpdate strategy). It now correctly uses React.memo to prevent unnecessary re-renders of the static HTML content. This approach is more performant and is the correct way to memoize function components.

  • Fixes: Incorrect shouldComponentUpdate implementation.
  • Impact: Improves the performance of rendering static HTML content in React.
  • Why: Makes use of modern react patterns, improves performance and fixes errors.
  • Implementation Details: Replaced shouldComponentUpdate with React.memo using custom comparison function to prevent re-renders.

https://react.dev/reference/react/memo

Refactors the StaticHtml component to use React.memo for efficient static HTML rendering, improving performance and compatibility.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Nov 27, 2025

🦋 Changeset detected

Latest commit: 081e010

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added pkg: react Related to React (scope) pkg: integration Related to any renderer integration (scope) labels Nov 27, 2025
Copy link
Copy Markdown
Member

@florian-lefebvre florian-lefebvre left a comment

Choose a reason for hiding this comment

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

I don't feel confident approving this given how sensitive it is (react is one of our most uses integrations), I'll wait for someone else. Anyways, can you add a patch changeset? Thank you!

StaticHtml.shouldComponentUpdate = () => false;

export default StaticHtml;
export default memo(StaticHtml, () => true);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just noting that memo is indeed noted as the equivalent at the end of https://react.dev/reference/react/Component#shouldcomponentupdate. Memo docs: https://react.dev/reference/react/memo

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.

Just noting that memo is indeed noted as the equivalent at the end of https://react.dev/reference/react/Component#shouldcomponentupdate. Memo docs: https://react.dev/reference/react/memo

Thanks for checking! You are correct that they serve the same purpose. But actually, shouldComponentUpdate is only for Class components, so it gets ignored here. The old code wasn't really running. Switching to memo ensures it works properly.

Sorry if I am wrong

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wouldn't be surprised if it worked, undocumented

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.

I wouldn't be surprised if it worked, undocumented

It appears the previous implementation was ineffective and had no measurable impact. Here is the evidence:

Screenshot 2026-03-07 at 14 48 18
View Benchmark Code
import React, { useState } from 'react';
import TestRenderer from 'react-test-renderer';
import { performance } from 'perf_hooks';

// --- 1. Setup: Render Counters ---
let oldRenderCount = 0;
let newRenderCount = 0;

// --- 2. Components ---
const OldStaticHtml = ({ value, name }) => {
	oldRenderCount++; // Track how many times React actually runs this function
	return React.createElement('astro-slot', { name, dangerouslySetInnerHTML: { __html: value } });
};
OldStaticHtml.shouldComponentUpdate = () => false;

const NewStaticHtml = React.memo(({ value, name }) => {
	newRenderCount++; // Track how many times React actually runs this function
	return React.createElement('astro-slot', { name, dangerouslySetInnerHTML: { __html: value } });
});

// --- 3. The "Real World" Parent ---
// This component simulates a page that re-renders frequently (e.g., a timer)
function App({ ComponentToTest, value }) {
	return React.createElement(
		'div',
		{ className: 'page' },
		React.createElement('h1', null, 'App Header'),
		React.createElement(ComponentToTest, { value, name: 'content' }),
	);
}

// --- 4. The Benchmark Runner ---
function runBenchmark(label, ComponentToTest, iterations = 10000) {
	const htmlContent = '<div>Static Content</div>';
	const root = TestRenderer.create(
		React.createElement(App, { ComponentToTest, value: htmlContent }),
	);

	// Reset counters
	oldRenderCount = 0;
	newRenderCount = 0;

	const start = performance.now();

	// Simulate 1000 parent re-renders
	for (let i = 0; i < iterations; i++) {
		root.update(React.createElement(App, { ComponentToTest, value: htmlContent }));
	}

	const end = performance.now();
	const count = label.includes('Old') ? oldRenderCount : newRenderCount;

	console.log(`${label}:`);
	console.log(` - Time: ${(end - start).toFixed(2)}ms`);
	console.log(` - Actual Render Calls: ${count}`);
	console.log('-----------------------------------');
}

console.log('🚀 Running Real-World Simulation (10000 Parent Updates)...\n');
runBenchmark('❌ Old (No Memo)', OldStaticHtml);
runBenchmark('✅ New (Memo)', NewStaticHtml);

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.

I ran some benchmarks to test the impact of memoization on components with heavy computations (simulating real-world scenarios like expensive data processing or complex logic).

The results show that in complex cases, the performance gain is massive—nearly 205x faster compared to unmemoized components 🚀

Astro js with react js benchmark(memo vs  unoptimised) screenshot
Click to see the Benchmark Code
// @vitest-environment jsdom
import { bench, describe } from 'vitest';
import React, { memo } from 'react';
import { render } from '@testing-library/react';

// Simulates heavy work (e.g., complex integration/data processing)
function findNthPrime(n: number): number {
  let count = 0;
  let num = 2;
  while (count < n) {
    let isPrime = true;
    for (let i = 2; i <= Math.sqrt(num); i++) {
      if (num % i === 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) count++;
    num++;
  }
  return num - 1;
}

const LIST_SIZE = 300;
const STATIC_ITEMS = Array.from({ length: LIST_SIZE / 2 }, (_, i) => `Static-${i}`);
const DYNAMIC_ITEMS = Array.from({ length: LIST_SIZE / 2 }, (_, i) => i);

const DynamicItem = ({ index, searchText }: { index: number; searchText: string }) => {
  const complexValue = findNthPrime(300 + (index % 50)).toExponential(); 
  return (
    <div>
      <strong>Item {index}:</strong> {searchText} 
      <small>(Calculated: {complexValue})</small>
    </div>
  );
};

const ListComponent = ({ searchText }: { searchText: string }) => (
  <div>
    {STATIC_ITEMS.map((item) => <div key={item}>Fixed: {item}</div>)}
    {DYNAMIC_ITEMS.map((i) => <DynamicItem key={i} index={i} searchText={searchText} />)}
  </div>
);

const MemoizedListComponent = memo(ListComponent, () => true);

const App = ({ text, memoEnabled }: { text: string; memoEnabled: boolean }) => (
  <div>
    {memoEnabled ? <MemoizedListComponent searchText={text} /> : <ListComponent searchText={text} />}
  </div>
);

const { rerender: rerenderNormal } = render(<App text="init" memoEnabled={false} />);
const { rerender: rerenderMemo } = render(<App text="init" memoEnabled={true} />);

describe('Complex React Performance', () => {
  bench('Normal (Re-calculates on every type)', () => {
    rerenderNormal(<App text={Math.random().toString()} memoEnabled={false} />);
  });

  bench('Memoized (Skips Calculation)', () => {
    rerenderMemo(<App text={Math.random().toString()} memoEnabled={true} />);
  });
});

This confirms that while simple renders might not show a huge difference, when heavy work is involved, preventing unnecessary re-renders is critical. This is exactly why deep dependency tracking (like what the React Compiler aims to do) is so valuable for complex UIs.

Anyway, I wanted to share these findings—sorry if I'm mistaken, but I believe this demonstrates the real-world value of these optimizations!

Comment thread .changeset/tough-days-sell.md Outdated
@sanjaiyan-dev
Copy link
Copy Markdown
Contributor Author

Hi @florian-lefebvre, sorry to bother you again. Could you please let me know if this PR looks good now ?

Copy link
Copy Markdown
Member

@sarah11918 sarah11918 left a comment

Choose a reason for hiding this comment

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

Thanks for making Astro better, @sanjaiyan-dev ! 🚀 Quick note from docs on the changeset!

Comment thread .changeset/tough-days-sell.md Outdated
sanjaiyan-dev and others added 2 commits February 16, 2026 20:58
More detailed changeset.

Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>
@sanjaiyan-dev
Copy link
Copy Markdown
Contributor Author

Thanks for making Astro better, @sanjaiyan-dev ! 🚀 Quick note from docs on the changeset!

Thanks 🙏

Copy link
Copy Markdown
Member

@sarah11918 sarah11918 left a comment

Choose a reason for hiding this comment

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

Approving for docs! 🥳

@sanjaiyan-dev
Copy link
Copy Markdown
Contributor Author

Hi @florian-lefebvre ,

I tested two scenarios:

  1. Micro-interactions (100 updates): Simulating a user typing quickly into an input field.
  2. Stress Test (100,000 updates): Simulating heavy, continuous state changes to measure raw CPU overhead.

📊 Benchmark Results

Scenario Old Implementation (Unoptimized) New Implementation (PR #14917) Improvement
100 Updates (UI Interaction) ~32.08ms ~12.17ms ~62% Faster
100k Updates (Stress Test) ~3658.26ms ~3030.30ms ~17% Faster

(Benchmarks run on Node.js with react-test-renderer)

💡 Key Takeaways

The 62% improvement on the smaller dataset is the real winner here. In real-world usage (like a search bar or a toggle next to heavy static content), this change significantly reduces the "cost per frame," helping to prevent input lag. By switching to React.memo, we are successfully short-circuiting the diffing process for the static HTML entirely.

🛠️ Click to view the benchmark code (benchmark.mjs)
import React from 'react';
import TestRenderer from 'react-test-renderer';
import { performance } from 'perf_hooks';

// 1. Old Implementation (Buggy, Pre-PR #14917)
const OldStaticHtml = ({ value, name }) => {
  if (!value) return null;
  return React.createElement('astro-slot', {
    name,
    dangerouslySetInnerHTML: { __html: value },
  });
};
OldStaticHtml.shouldComponentUpdate = () => false; // Ignored by React!

// 2. New Implementation (Optimized via PR #14917)
const NewStaticHtml = React.memo(({ value, name }) => {
  if (!value) return null;
  return React.createElement('astro-slot', {
    name,
    dangerouslySetInnerHTML: { __html: value },
  });
}, () => true); // Forces React to skip re-renders

// 3. Parent Component to simulate updates
function App({ count, ComponentToTest }) {
  // Simulate a massive block of static HTML generated by Astro
  const heavyHtml = "<div class='heavy'>".repeat(100) + "Astro Content" + "</div>".repeat(100);
  
  return React.createElement('div', null, 
    React.createElement('h1', null, `Render Count: ${count}`),
    React.createElement(ComponentToTest, { value: heavyHtml, name: "default" })
  );
}

// 4. Benchmark Runner
function runBenchmark(name, ComponentToTest, iterations = 100_000) {
  // Initial render
  const root = TestRenderer.create(React.createElement(App, { count: 0, ComponentToTest }));
  
  const start = performance.now();
  
  // Trigger continuous re-renders (simulating rapid state changes)
  for (let i = 1; i <= iterations; i++) {
    root.update(React.createElement(App, { count: i, ComponentToTest }));
  }
  
  const end = performance.now();
  console.log(`${name}: ${(end - start).toFixed(2)}ms for ${iterations} updates`);
}

console.log('🚀 Benchmarking StaticHtml React Integration...\n');
runBenchmark('❌ Old StaticHtml (Unoptimized)', OldStaticHtml);
runBenchmark('✅ New StaticHtml (Optimized)', NewStaticHtml);

@sanjaiyan-dev
Copy link
Copy Markdown
Contributor Author

<StaticHTML> benchmark before <StaticHTML> benchmark after

@sanjaiyan-dev
Copy link
Copy Markdown
Contributor Author

Hi @florian-lefebvre and @matthewp 👋

I totally understand the need to be cautious here since React hydration is so sensitive.

Are there any specific edge cases (like nested islands or dynamic slots) you're worried about? I'd be happy to write some extra tests for those to prove it's safe and help you feel confident moving this forward.

Just let me know how I can help :)

Copy link
Copy Markdown
Member

@Princesseuh Princesseuh left a comment

Choose a reason for hiding this comment

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

Researched this a bit, asked AI etc and it seems fine.

@Princesseuh Princesseuh merged commit 769265b into withastro:main Mar 24, 2026
20 of 21 checks passed
@astrobot-houston astrobot-houston mentioned this pull request Mar 24, 2026
@sanjaiyan-dev
Copy link
Copy Markdown
Contributor Author

Researched this a bit, asked AI etc and it seems fine.

Thanks :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: integration Related to any renderer integration (scope) pkg: react Related to React (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants