feat: identify different JSX frameworks during SSR#15700
feat: identify different JSX frameworks during SSR#15700ocavue wants to merge 15 commits intowithastro:mainfrom
Conversation
🦋 Changeset detectedLatest 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 |
| 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)) { |
There was a problem hiding this comment.
This is the only change that I really need in astro package.
| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
These changes are just for the new test case. They are harmless and even useful anyway.
|
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 can you add this to the React integration as well? |
|
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 |
| "dev": "astro-scripts dev \"src/**/*.ts\"" | ||
| }, | ||
| "dependencies": { | ||
| "@astrojs/internal-helpers": "workspace:*", |
There was a problem hiding this comment.
You can't use workspace here I think
There was a problem hiding this comment.
Why can't I use workspace here? It seems that @astrojs/markdoc is using the same method here:
astro/packages/integrations/markdoc/package.json
Lines 64 to 65 in 67f9f96
There was a problem hiding this comment.
I thought it wasn't supported but I just checked https://pnpm.io/workspaces#publishing-workspace-packages, TIL!
|
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 What doesn't fit Qwik: The include/exclude pattern is a worse DX fit for Qwik. Qwik's Suggestion: If That would close the known limitation and make the 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. |
Is this a convention that all Qwik components have? |
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. |
Yes, it's the standard convention. component$() is Qwik's component API and all components created with it have that name. |
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. |
|
@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. |
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
IMHO, the implementation behind For example, the following React component will pass the 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:
Although I admit in particular, the current |
Done |
I agree with @matthewp that there isn't a real breaking change. It only breaks a user's project if
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. |
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. |
|
"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. |
|
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 |
There is an example in astro's repo itself: In 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 Before this PR, this example can work. After this PR, $ 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 |
|
@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. |
sarah11918
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Co-authored-by: Sarah Rainsberger <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
Updates the internal logic during SSR by providing additional metadata for UI framework integrations.
Problem
When multiple JSX renderers are used (e.g. React + Preact), Astro doesn't really know which renderer it should use to render a
.jsxfile during SSR. Astro will try to guess the renderer on a best-effort basis, for example:QwikComponent, then it won't be treated as a React component (code link)<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
preactduring SSR. This could cause subtle bugs, such as hydration mismatches when usingpreactto render a React component. Currently we just patchconsole.errorand pretend it's not a problem.Idea
When multiple JSX renderers are used in the same project, users SHOULD specify the
include/excludepatterns to identify the components that should be rendered by each renderer. Otherwise a warning is already shown to the user.These
include/excludepatterns 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:
In
packages/astro/src: I pass themetadatato therender.ssr.check()function. Notice that this matches the existing signature of thecheck()function.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:loadcomponents, andclient:onlycomponents.In
packages/integrations/preact: I reuse theinclude/excludeoptions passed to the integration. During the SSR phase, these options are used to check the component path frommetadata.componentUrl.For now, I only update
@astrojs/preactin 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/reactand third-party renderers like@qwikdev/astro.Update:
@astrojs/reactis also updated as required.In
@astrojs/internal-helpers: I added a newcreateFilterutility. This is just a fork of the existingcreateFilterfromrollup, but it does not use any Node.js API under the hood. The Cloudflare integration won't like me to importrollupduring SSR.Potential breaking changes
Let's say we have a project with the following config:
And we have the following file structure:
Before this PR, all three components would be rendered by
preactduring SSR.After this PR,
Button.jsxwill be rendered bypreact,Box.jsxwill be rendered byreact, andTooltip.jswon't be rendered by any renderer, which will cause a build-time error.Alternative Approach
In my current approach, I let
@astrojs/preacthandle its owninclude/excludepatterns and decide whether to render a component by itself. In an alternative approach, we could let the Astro core handle theinclude/excludepatterns. I didn't choose this alternative approach becauseinclude/excludeare 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.jsxfile 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 havemetadata.componentUrl, because the Astro compiler only emitsclient:component-pathfor hydrated components. The filter can't apply in this case, andcheck()falls back to its existing try-render behavior. Therefore, my current PR only improves the JSX components withclient:*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.