Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: identify different JSX frameworks during SSR#15700

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

feat: identify different JSX frameworks during SSR#15700
ocavue wants to merge 15 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 mainly these 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.

    Update: @astrojs/react is also updated as required.

  4. In @astrojs/internal-helpers: I added a new createFilter utility. This is just a fork of the existing createFilter from rollup, but it does not use any Node.js API under the hood. The Cloudflare integration won't like me to import rollup during SSR.

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

Added changeset files for all updated packages.

@changeset-bot
Copy link

changeset-bot bot commented Feb 27, 2026

🦋 Changeset detected

Latest commit: e3d0dd2

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 (e3d0dd2) with main (e6e146c)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (cc9ee02) during the generation of this report, so e6e146c was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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)

"dev": "astro-scripts dev \"src/**/*.ts\""
},
"dependencies": {
"@astrojs/internal-helpers": "workspace:*",
Copy link
Member

Choose a reason for hiding this comment

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

You can't use workspace here I think

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why can't I use workspace here? It seems that @astrojs/markdoc is using the same method here:

"dependencies": {
"@astrojs/internal-helpers": "workspace:*",

Copy link
Member

Choose a reason for hiding this comment

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

I thought it wasn't supported but I just checked https://pnpm.io/workspaces#publishing-workspace-packages, TIL!

@thejackshelton
Copy link

Hey @ocavue, great work on this! The speculative rendering + console.error patching approach has always been fragile, so moving toward explicit routing is a solid direction.

I maintain Astro's Qwik integration and wanted to flag a few things since the PR description mentions us as a renderer that should adopt this approach:

What should stay if this PR merges as-is: The Component.name === 'QwikComponent' checks should stay for now. Qwik components don't use client:load/client:visible/etc., Qwik uses resumability instead of hydration. Since metadata.componentUrl is only populated for hydrated components (the known limitation you noted), removing those checks would cause React/Preact/Solid to speculatively render Qwik components again.

What doesn't fit Qwik: The include/exclude pattern is a worse DX fit for Qwik. Qwik's check() uses structural detection via isQwikComponent(). it inspects the component itself rather than relying on file paths. This means Qwik components work without users having to organize files into framework-specific directories or add include patterns to their config. Asking Astro Qwik users to adopt path-based filtering would be a regression from "it just works."

Suggestion: If componentUrl were populated for all components (not just hydrated ones), the path-based filtering would work reliably for the frameworks that need it (React/Preact/Solid), while Qwik could continue using structural detection without any changes.

That would close the known limitation and make the QwikComponent name checks in each integration truly redundant. Users configure preact({ include: ['**/preact/**'] }), a Qwik component at src/components/qwik/Counter.tsx would be rejected by Preact's path filter before it ever reaches the speculative rendering code. Today, without componentUrl on SSR-only components, the hardcoded name check is the only thing preventing that.

Happy to help test this against @qwikdev/astro once it lands, just want to make sure the existing integration isn't broken and that we're not expected to adopt a pattern that doesn't fit Astro Qwik's model.

@matthewp
Copy link
Contributor

What should stay if this PR merges as-is: The Component.name === 'QwikComponent' checks should stay for now.

Is this a convention that all Qwik components have?

@matthewp
Copy link
Contributor

Asking Astro Qwik users to adopt path-based filtering would be a regression from "it just works."

I don't think anyone is asking integrations to adopt this behavior. It makes sense for React, Preact, and Solid since all of those use regular function exports that are not easily distinguishable from each other.

@thejackshelton
Copy link

What should stay if this PR merges as-is: The Component.name === 'QwikComponent' checks should stay for now.

Is this a convention that all Qwik components have?

Yes, it's the standard convention. component$() is Qwik's component API and all components created with it have that name.

@thejackshelton
Copy link

Asking Astro Qwik users to adopt path-based filtering would be a regression from "it just works."

I don't think anyone is asking integrations to adopt this behavior. It makes sense for React, Preact, and Solid since all of those use regular function exports that are not easily distinguishable from each other.

That's good to hear. the PR description mentioned @qwikdev/astro as a renderer that should use this approach, so I wanted to make sure that wasn't the expectation. If Qwik can keep using structural detection as-is, we're all good.

@matthewp
Copy link
Contributor

matthewp commented Mar 1, 2026

@sarah11918 I don't think there's a breaking change here as far as I can tell. This logic only applies when using include/exclude filters, the user is opting into only those files being considered already, this is just fixing a gap.

@ocavue
Copy link
Contributor Author

ocavue commented Mar 1, 2026

What should stay if this PR merges as-is: The Component.name === 'QwikComponent' checks should stay for now.

I agree. Currently, because of the "Known limitation" I mentioned in the PR description, the check is not sufficient. I still keep all existing (and fragile) checking code as it is.

What I hope is that once I update the Astro compiler side and make componentUrl populated for all components, as you mentioned, I can remove these checks in one day. That would be a different PR, though.

The include/exclude pattern is a worse DX fit for Qwik. Qwik's check() uses structural detection via isQwikComponent()

IMHO, the implementation behind isQwikComponent() is not sound. It uses component.name and component.toString(), which are not strict.

For example, the following React component will pass the isQwikComponent() check incorrectly:

import React from 'react'

function QwikComponent() {
    return React.createElement(
      "div", 
      {}, 
      "This is a React component " + 
         "with an unusual name " + 
         "and some magic text in the function body " + 
         "_jsx_q."
    )
}

It seems that the only reliable ways in theory to check if a variable is a component are:

  1. Path-based filtering. It's users' responsibility to specify the correct path.
  2. maybeComponent instanceof MyFrameworkClass
    (and similar MyFrameworkComponent.isPrototypeOf(maybeComponent))
  3. maybeComponent.__internal_label__ === Symbol.for("my_framework_symbol")

Although I admit in particular, the current isQwikComponent() implementation can work almost in all real use cases.

@github-actions github-actions bot added the pkg: react Related to React (scope) label Mar 1, 2026
@ocavue
Copy link
Contributor Author

ocavue commented Mar 1, 2026

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

Done

@ocavue
Copy link
Contributor Author

ocavue commented Mar 1, 2026

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

I don't think there's a breaking change here as far as I can tell.

I agree with @matthewp that there isn't a real breaking change. It only breaks a user's project if

  1. The user is using multiple JSX frameworks (e.g., React + SolidJS), and
  2. The user has already added include/exclude filters as shown in the docs "Combining multiple JSX frameworks", and
  3. The include/exclude filters are incorrect (e.g., missing some components in the include filter).

In this case, the user's project will fail to build after updating to the latest versions.

I would say it's more like a bug fix than a major change. I also updated the error message in this commit to better indicate the reason for these users.

@thejackshelton
Copy link

thejackshelton commented Mar 1, 2026

IMHO, the implementation behind isQwikComponent() is not sound. It uses component.name and component.toString(), which are not strict.

Haha yeah, this is a case where it's technically unsound but practically never happens. They could probably be improved further with linter tooling but that's still easing symptoms. We do mention the path based way to do it, and mention it in the README, but it is more of a precaution.

Symbols actually seem the most technically feasible of those options. That would mean framework authors need to create a unique symbol for each framework.

@sarah11918
Copy link
Member

"It's only a breaking change if..."

In the past, we have considered the user whose project worked (even if only inadvertently) who might notice a change, which is why I brought this up. But if you're choosing not to here, that's your call! It's only my responsibility to point this out.

@matthewp
Copy link
Contributor

matthewp commented Mar 2, 2026

I'm not seeing the scenario where a user's project would break after this change. Can you give a concrete example @ocavue ? We are using the same include/exclude filter, so an "incorrect" one would still be incorrect when it is called against this code.

The real change here is that we still always do the check against frameworks, which is imperfect for JSX frameworks (React, Solid, and Preact at least) and causes double renders and other weirdness. This seems like a bug fix to me.

@ocavue
Copy link
Contributor Author

ocavue commented Mar 2, 2026

@matthewp

I'm not seeing the scenario where a user's project would break after this change. Can you give a concrete example

There is an example in astro's repo itself:

In packages/astro/test/fixtures/static-build-frameworks/, you can find three JSX components:

src/
  components/
    Nested.jsx    <-- A React component 
    RCounter.jsx  <-- A React component 
    PCounter.jsx  <-- A Preact component

Here is its astro.config.mjs:

import preact from '@astrojs/preact';
import react from '@astrojs/react';
import { defineConfig } from 'astro/config';

// https://astro.build/config
export default defineConfig({
	integrations: [react({
		include: ["**/react/*", "**/RCounter.jsx"]
	}), preact({
		include: ["**/preact/*", "**/PCounter.jsx"]
	})],
});

Notice that src/components/Nested.jsx doesn't actually match any of the include filter.

Before this PR, this example can work. react({...}) is the first integration in astro.config.mjs. Astro uses react to render all three JSX components during SSR. Not quite right technically, but users won't see any error anyway.

After this PR, RCounter.jsx is rendered by react (same as before 😐), PCounter.jsx is rendered by preact (better 🥰), but Nested.jsx will not render at all (😱). The following error will show:

$ cd packages/astro/test/fixtures/static-build-frameworks/
$ ./node_modules/.bin/astro build

[ERROR] Error: Unable to render NestedCounter!

This component likely uses @astrojs/react, @astrojs/preact, @astrojs/solid-js or @astrojs/vue (jsx),
but Astro encountered an error during server-side rendering.

Please ensure that NestedCounter:
1. Does not unconditionally access browser-specific globals like `window` or `document`.
   If this is unavoidable, use the `client:only` hydration directive.
2. Does not conditionally return `null` or `undefined` when rendered on the server.
3. If using multiple JSX frameworks at the same time (e.g. React + Preact), pass the correct `include`/`exclude` options to integrations.

If you're still stuck, please open an issue on GitHub or join us at https://astro.build/chat.

(Notice that the (3.) in the error message is new added by this pull request.)

To fix this test fixture, I move the Nested.jsx file under react/ in
commit f27186c so that it won't throw error.

@matthewp
Copy link
Contributor

matthewp commented Mar 2, 2026

@ocavue Thanks for the explanation. I consider this a bug then, the user has explicitly opted that component from being treated as React, and that we did so by mistake is a bug.

Copy link
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.

Just some comments on the changesets from me for your consideration!

'astro': minor
---

Passes an optional `metadata` object to `render.ssr.check()` function as the fourth argument.
Copy link
Member

Choose a reason for hiding this comment

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

Is this something internal? It's not clear to me what a user would or could do differently now... or whether this is something I need to even care about in my project.

Is this something like:

Updates the internal logic when (DOING X)... by providing additional metadata about (THING Y)?

Because in that case, we'd normally describe the change as it relates to someone's Astro project, and if it's under the hood, we'd just say that "thing is happening differently now". We wouldn't normally describe things in detail that the user wouldn't touch themselves.

For adding a new option/feature, we have documentation with examples of how to describe your new thing, and how it's useful https://contribute.docs.astro.build/docs-for-code-changes/changesets/#new-features , but that doesn't seem like what's happening here, so I'm guessing a more user-facing statement like above is the most helpful thing to communicate about this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is this something internal?

Mostly correct. Outside the Astro team, there are maybe only 3 people who care about this API change (i.e., third-party JSX frameworks Astro integration maintainer). For almost all Astro users, they don't need to know this.

I have updated the changeset.

ocavue and others added 2 commits March 3, 2026 03:17
@matthewp matthewp self-assigned this Mar 2, 2026
ocavue added 2 commits March 3, 2026 12:41
Updates the internal logic during SSR by providing additional metadata for UI framework integrations.
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) pkg: react Related to React (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants