Skip to content

fix(solid-router): prevent client effects running on server #4621

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 15, 2025

Conversation

Ingramz
Copy link
Contributor

@Ingramz Ingramz commented Jul 11, 2025

This is exploration of the comment I posted on #4574 (comment).

Using Solid.createRenderEffect in SSR will execute the effects after the render takes place. To prevent server from doing meaningless work, we can use the useLayoutEffect utility function, which will make the server use Solid.createEffect instead, preventing such effects from being ran on server. Client side there are no differences as Solid.createRenderEffect is still used. This is closer to how it works in React version today.

To properly test this, we need true SSR with environment: 'node' and solidPlugin({ ssr: true }) in Vite, because:

  1. jsdom environment provides window, which causes wrong branch in useLayoutEffect to be chosen. This is fixable by replacing typeof window !== 'undefined' check with isServer from solid-js/web, resulting in a marginally smaller client bundle, too (This was decided against in order to keep the code as similar to react-router).
  2. Without ssr: true, we do not have access to renderToString.

For now I added the SSR config to the same vite config via --mode switch. However I am unhappy with the way how now package.json script needs to launch vitest twice: "test:unit": "vitest && vitest --mode server". Suggestions on better format or alternatives would be most welcome.

Copy link

nx-cloud bot commented Jul 11, 2025

View your CI Pipeline Execution ↗ for commit ec76f6a

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 4m 52s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 40s View ↗

☁️ Nx Cloud last updated this comment at 2025-07-15 20:19:22 UTC

Copy link

pkg-pr-new bot commented Jul 11, 2025

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/arktype-adapter@4621

@tanstack/directive-functions-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/directive-functions-plugin@4621

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/eslint-plugin-router@4621

@tanstack/history

npm i https://pkg.pr.new/TanStack/router/@tanstack/history@4621

@tanstack/react-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router@4621

@tanstack/react-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-devtools@4621

@tanstack/react-router-with-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-with-query@4621

@tanstack/react-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start@4621

@tanstack/react-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-client@4621

@tanstack/react-start-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-plugin@4621

@tanstack/react-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-server@4621

@tanstack/router-cli

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-cli@4621

@tanstack/router-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-core@4621

@tanstack/router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools@4621

@tanstack/router-devtools-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools-core@4621

@tanstack/router-generator

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-generator@4621

@tanstack/router-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-plugin@4621

@tanstack/router-utils

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-utils@4621

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-vite-plugin@4621

@tanstack/server-functions-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/server-functions-plugin@4621

@tanstack/solid-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router@4621

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router-devtools@4621

@tanstack/solid-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start@4621

@tanstack/solid-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-client@4621

@tanstack/solid-start-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-plugin@4621

@tanstack/solid-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-server@4621

@tanstack/start-client-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-client-core@4621

@tanstack/start-plugin-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-plugin-core@4621

@tanstack/start-server-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-core@4621

@tanstack/start-server-functions-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-functions-client@4621

@tanstack/start-server-functions-fetcher

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-functions-fetcher@4621

@tanstack/start-server-functions-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-functions-server@4621

@tanstack/valibot-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/valibot-adapter@4621

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/TanStack/router/@tanstack/virtual-file-routes@4621

@tanstack/zod-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/zod-adapter@4621

commit: ec76f6a

@Ingramz
Copy link
Contributor Author

Ingramz commented Jul 11, 2025

The SSR test passed on CI so overall this approach seems to be working at least.

I did try Vitest's test projects feature to combine client and server tests into same config, but I could not get it working, could very well be that it isn't the right tool for the job. Giving up on trying to improve it any further for now. Currently implemented approach is borrowed from solid-primitives.


As an added note to the main item of this pull request, Solid.createEffect on server is merely an empty function, which makes me think that it's probably not even the best solution to use useLayoutEffect instead.

Transitioner largely seems to be consisting of client only behaviors and one might as well just short circuit it at some specific point with if (isServer) return null to reduce the amount of code built for and executed on the server, though the impact might be negligible as it is a micro optimization at best. I'll think about it a little in the meanwhile.

@brenelz
Copy link
Contributor

brenelz commented Jul 12, 2025

Im honestly not sure about this PR. Is there any specific issue this solves or just trying to run less on the server when its not needed?


export const useLayoutEffect =
typeof window !== 'undefined' ? Solid.createRenderEffect : Solid.createEffect
export const useLayoutEffect = isServer
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure I like this indirection but i guess it was kinda there already

@Ingramz
Copy link
Contributor Author

Ingramz commented Jul 12, 2025

Im honestly not sure about this PR. Is there any specific issue this solves or just trying to run less on the server when its not needed?

The latter. Just rubs me the wrong way that there are effects queued and then ran after server has already rendered the output.

At closer inspection I'd say that the entire Transitioner is pretty much unnecessary on server side, the code that's before the client effects is just setup for the effects. So we could just introduce if (isServer) return null on the first line of the component and leave the rest as it was before. Optionally do away with useLayoutEffect as well as it seems unused and perhaps too misleading in solid-router. Let me know if this is acceptable and I will amend the PR accordingly.

@schiller-manuel
Copy link
Contributor

we should keep react and solid impl aligned when it comes to Transitioner. if there are changes applicable for both then please go ahead

@Ingramz
Copy link
Contributor Author

Ingramz commented Jul 12, 2025

we should keep react and solid impl aligned when it comes to Transitioner. if there are changes applicable for both then please go ahead

I reworked the commits such that both React and Solid Transistioners now bail out as early out as possible on the server. I may have misjudged the usefulness of Solid's native isServer in this particular instance as currently the code from node_modules isn't optimized in production builds for server, so it would have ended being a runtime check anyway with no dead code elimination.

On Solid's side I am not touching the useLayoutEffect util any longer. I tend to agree with @brenelz that the indirection is undesirable and since nothing is actively using it in solid-router currently, it shouldn't be landing into client bundles unless the developer imports it themselves. Perhaps it's a good candidate for removal in future, but that's out of scope for this PR. Turns out we can't avoid it if the intent is to keep the react and solid implementations similar, a necessary evil.

For now I do not have any further changes to propose, the change set as it is fixes my concern of Solid doing more work than React and I guess there's a slight win for React as well unless somehow returning early on server is illegal.

@schiller-manuel
Copy link
Contributor

what would be needed to fully support isServer when building the server part?

@Ingramz
Copy link
Contributor Author

Ingramz commented Jul 12, 2025

what would be needed to fully support isServer when building the server part?

Nothing on the library side other than using it, it should be a bundle time optimization. I cannot find my test code for server output anymore, everywhere I look it's just a plain import for server code, so I must have been wrong. All the ones I could find are client code where it does evaluate to false and strip away dead branches.


The tests failed due going against hook rules in React 🤡. I am giving up. I can't make this optimization work in both without diverging the implementations, so I guess I'll use useLayoutEffect after all and call it a day. That way both will look similar and behave similar. It's ready for review now, for real.

@Ingramz Ingramz force-pushed the solid-client-effects branch from d6f745d to b5c691d Compare July 12, 2025 22:30
@schiller-manuel
Copy link
Contributor

can we have separate ClientTransitioner and a noop ServerTransitioner impl that we render depending on the env?

@Ingramz
Copy link
Contributor Author

Ingramz commented Jul 13, 2025

Yeah I suppose that can be done. It's effectively a client-only component, so we can probably avoid creating the no-op if we selectively render it.

We could simply check for server at the usage and then not render it. This would be my preferred choice as Transitioner cannot be used externally and there is already some conditional rendering being done at the only usage.

{!router.isServer && <Transitioner />}

Alternatively we could hide the isServer check inside Transitioner. This would only be an improvement in my opinion if it was part of public API.

function Transitioner() {
  const router = useRouter()
  return router.isServer ? null :  <ClientTransitioner />
}

Or export a Transitioner impl based on some environment check which probably is the most literal interpretation of your suggestion:

export const Transitioner = typeof window !== 'undefined' ? ClientTransitioner : (() => null)

Depending on bundler setup this can optimize away the unused branch for a given environment, but it's not entirely clear to me when these optimizations happen and don't happen, so I'd save this variant for a discussion in the future instead. When no optimizations take place, we'd end up shipping both branches to client, which is no better than just opting to do the check during runtime using options 1 or 2.

Lastly, I was thinking of just wrapping Transitioner with ClientOnly:

<ClientOnly>
  <Transitioner />
</ClientOnly>

Unit tests seem to pass with this, but compared to the first option, I think this will delay the rendering of Transitioner in client by one render cycle, which might be fine, though I am unsure. Either way the ClientOnly usage will be some overhead and I'm more confident in option 1 working right than this.

If it wasn't obvious already, I'd just stick to option 1 for now.

@schiller-manuel
Copy link
Contributor

schiller-manuel commented Jul 13, 2025

solution 1 looks good to me.

FYI i am adding a static isServer that is resolved by the bundler via exports conditions in #4648

@Ingramz Ingramz force-pushed the solid-client-effects branch from b5c691d to ec76f6a Compare July 15, 2025 07:17
@schiller-manuel schiller-manuel merged commit ba590c2 into TanStack:main Jul 15, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants