Skip to content

feat: identify different JSX frameworks during SSR#15700

Open
ocavue wants to merge 3 commits intowithastro:mainfrom
ocavue-forks:ocavue/multi-jsx-3
Open

feat: identify different JSX frameworks during SSR#15700
ocavue wants to merge 3 commits intowithastro:mainfrom
ocavue-forks:ocavue/multi-jsx-3

Conversation

@ocavue
Copy link
Contributor

@ocavue ocavue commented Feb 27, 2026

Problem

When multiple JSX renderers are used (e.g. React + Preact), Astro doesn't really know which renderer it should use to render a .jsx file during SSR. Astro will try to guess the renderer on a best-effort basis, for example:

  • if a component is named QwikComponent, then it won't be treated as a React component (code link)
  • if a component outputs <undefined> in the HTML, then it won't be treated as a Preact component (code link)

These guesses are fragile and error-prone. For example, in #15341, a React component is rendered using preact during SSR. This could cause subtle bugs, such as hydration mismatches when using preact to render a React component. Currently we just patch console.error and pretend it's not a problem.

Idea

When multiple JSX renderers are used in the same project, users SHOULD specify the include/exclude patterns to identify the components that should be rendered by each renderer. Otherwise a warning is already shown to the user.

These include/exclude patterns are currently only used by the Vite JSX transform plugins. We can reuse them during the SSR phase to pick the correct renderer for a JSX component.

Changes

This PR includes three main changes:

  1. In packages/astro/src: I pass the metadata to the render.ssr.check() function. Notice that this matches the existing signature of the check() function.

  2. In packages/astro/test: I build a comprehensive test fixture with two mock renderers (woof/meow) that demonstrates the filter pattern. Tests cover different scenarios: SSR-only components, client:load components, and client:only components.

  3. In packages/integrations/preact: I reuse the include/exclude options passed to the integration. During the SSR phase, these options are used to check the component path from metadata.componentUrl.

    For now, I only update @astrojs/preact in this PR because I want to keep the PR small and focused. But in an ideal world, we should update all the JSX renderers to use this approach, including official renderers like @astrojs/react and third-party renderers like @qwikdev/astro.

Potential breaking changes

Let's say we have a project with the following config:

defineConfig({
  integrations: [
    preact({
      include: ['**/preact/jsx/*.jsx'],
    }),
    react({
      include: ['**/react/jsx/*.jsx'],
    }),
  ],
})

And we have the following file structure:

src/
  components/
    preact/
      jsx/
        Button.jsx  <-- A Preact component with .jsx extension
      js/
        Tooltip.js  <-- A Preact component with .js extension
    react/
      jsx/
        Box.jsx     <-- A React component with .jsx extension

Before this PR, all three components would be rendered by preact during SSR.
After this PR, Button.jsx will be rendered by preact, Box.jsx will be rendered by react, and Tooltip.js won't be rendered by any renderer, which will cause a build-time error.

Alternative Approach

In my current approach, I let @astrojs/preact handle its own include/exclude patterns and decide whether to render a component by itself. In an alternative approach, we could let the Astro core handle the include/exclude patterns. I didn't choose this alternative approach because include/exclude are technically renderer-specific. Although almost all Vite plugins use these patterns since this pattern is from Rollup, a Vite plugin could prefer to use other patterns to identify if a .jsx file is a valid component, especially in the future when Rolldown replaces Rollup as the default bundler.

Known limitation

SSR-only components (those without any client:* directive) don't have metadata.componentUrl, because the Astro compiler only emits client:component-path for hydrated components. The filter can't apply in this case, and check() falls back to its existing try-render behavior. Therefore, my current PR only improves the JSX components with client:* directive.

I can submit another purpose to the compiler repo with more details, if the team thinks this is a good idea.

Docs

No ready.

@changeset-bot
Copy link

changeset-bot bot commented Feb 27, 2026

🦋 Changeset detected

Latest commit: a5bfde2

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: preact Related to Preact (scope) pkg: integration Related to any renderer integration (scope) pkg: astro Related to the core `astro` package (scope) labels Feb 27, 2026
@codspeed-hq
Copy link

codspeed-hq bot commented Feb 27, 2026

Merging this PR will not alter performance

✅ 18 untouched benchmarks


Comparing ocavue-forks:ocavue/multi-jsx-3 (a5bfde2) with main (4db2089)

Open in CodSpeed

for (const r of renderers) {
try {
if (await r.ssr.check.call({ result }, Component, props, children)) {
if (await r.ssr.check.call({ result }, Component, props, children, metadata)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the only change that I really need in astro package.

Comment on lines +183 to +190
// Attempt: use explicitly passed renderer name for custom renderers. This is put
// last to avoid potential conflicts with the previous implementations.
if (!renderer && metadata.hydrateArgs) {
const rendererName = metadata.hydrateArgs;
if (typeof rendererName === 'string') {
renderer = renderers.find(({ name }) => name === rendererName);
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These changes are just for the new test case. They are harmless and even useful anyway.

@matthewp
Copy link
Contributor

Ok cool, so this is basically all handled inside of the integration renderers. This prevents us from having to run the code if we already know its outside of the directory scope, is that right?

@ocavue
Copy link
Contributor Author

ocavue commented Feb 27, 2026

Ok cool, so this is basically all handled inside of the integration renderers. This prevents us from having to run the code if we already know its outside of the directory scope, is that right?

That's correct.

@ocavue ocavue marked this pull request as ready for review February 27, 2026 22:14
@matthewp
Copy link
Contributor

@ocavue can you add this to the React integration as well?

@sarah11918
Copy link
Member

Just noting that since there are potential breaking changes here, this would need some kind of breaking change guidance: https://contribute.docs.astro.build/docs-for-code-changes/changesets/#breaking-changes

It would probably be a good idea to include any of the framework integrations with breaking changes to this list, too: https://v6.docs.astro.build/en/guides/upgrade-to/v6/#official-astro-integrations (Right now, it's just the adapters, but if this affects e.g. something previously rendered as one JSX framework could now be rendered as another, we should have a major changeset for that integration with the breaking change notification and how to revert to previous behaviour, and then we can just link to the changelong from the upgrade guide like we do for adapters currently)

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

Labels

pkg: astro Related to the core `astro` package (scope) pkg: integration Related to any renderer integration (scope) pkg: preact Related to Preact (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants