Skip to content
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

Rendering components outside of view context #201

Closed
dennyf opened this issue Feb 1, 2020 · 24 comments · Fixed by #2065
Closed

Rendering components outside of view context #201

dennyf opened this issue Feb 1, 2020 · 24 comments · Fixed by #2065
Labels

Comments

@dennyf
Copy link

dennyf commented Feb 1, 2020

I'm trying to find if there is a way to render a component outside of the view context. I'm working on a feature to register a custom tag in my markdown processor - so basically, I want to replace the custom tag/shortcode with the markup generated by a component.
So, I'm wondering if there is a way to call the component to generate the markup from another class, outside the view context?
Thanks!

@dylnclrk
Copy link
Contributor

dylnclrk commented Feb 3, 2020

Not sure if I understand correctly, and maybe this is too hacky, but could you set up a fake view context in much the same way that render_inline does?

@joelhawksley
Copy link
Member

@dennyf - @dylnclrk is probably on the right track.

I'd love to see a PR for this. Let me know if you'd like to pair program on it 😄

@dennyf
Copy link
Author

dennyf commented Feb 5, 2020

@dylnclrk @joelhawksley Thank you both! I did something similar too, the following code worked fine for a very simple component :

component = MyComponent.new(title: "My Component")
component.render_in(ActionView::Base.new)

but it felt too hacky and probably it's not a good idea to call render_in directly.
I'll dig into the ActionView Component code to better understand how it works internally - I'll submit a PR if I can get to find a good way to implement this.

@joelhawksley
Copy link
Member

@dennyf render_in is the public interface for components - it's definitely the right way in!

What if we had changed render_in from:

def render_in(view_context, *args, &block)

to:

def render_in(view_context = ActionView::Base.new, *args, &block)

I'm not sure if that would work in all cases, but I think we'd be fine to use that kind of fallback.

@dennyf
Copy link
Author

dennyf commented Feb 7, 2020

@joelhawksley Thanks, that's good to hear! Actually I just noticed that Rails is throwing a warning when initializing ActionView::Base with no arguments:

DEPRECATION WARNING: ActionView::Base instances should be constructed with a lookup context, assignments, and a controller.

so I guess it won't be as simple as that, I think it will be an overkill to initialize it with all the required parameters... I'll see if I can get any other ideas.

@joelhawksley
Copy link
Member

@dennyf It might be possible to provide all of those things, FWIW. Let me know if you want to have a look at this together: [email protected]

@dennyf
Copy link
Author

dennyf commented Feb 11, 2020

@joelhawksley Sorry for the late reply - my schedule is very ovestretched these days - the best I can do is spend a few minutes here and there when I get any free time. So can we continue the discussion here in this issue? In this way I can be more flexible with my time.

As for providing all the options - I noticed that at this point view_context is the only parameter that's actually required (although the warning also says that assignments and controller are needed), basically the following works fine without causing any warnings:

component = MyComponent.new(title: param)
# not sure if that's the best way to create a lookup context
lookup_context = ActionView::LookupContext.new(ActionController::Base.view_paths)
view = ActionView::Base.new(lookup_context, {})
component.render_in(view)

so technically it should be okay to go without passing a controller instance for now... or do you think it's better to pass a controller as well in case things change in the future?

@pinzonjulian
Copy link

Hi everyone
I'm looking into caching (#42 & #234 ) and my research (still in early stages) is pointing me towards LookupContext too. Do you guys know enough about it? I could use a quick lesson on it if you have the time.

@stale
Copy link

stale bot commented Aug 29, 2020

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the stale label Aug 29, 2020
@stale stale bot closed this as completed Sep 5, 2020
@ramontayag
Copy link

This doesn't work in 2.40.0 when helpers are used:

NoMethodError: undefined method `view_context' for nil:NilClass

   13  module ViewComponent
   14    class Base < ActionView::Base
  181      def helpers
  182        if view_context.nil?
  191        end
❯ 193        @__vc_helpers ||= controller.view_context
  194      end
  524    end
  525  end

@boardfish
Copy link
Collaborator

@ramontayag Use of helpers means that your component is coupled to the view context. You could consider including the helpers directly on the component class so that it's decoupled from the view context. Alternatively, Joel's solution might work for you.

@ramontayag
Copy link

ramontayag commented Sep 14, 2021

I see. Is it generally recommended to override that method?

In the past, I've done something like this:

def helpers
  return @helpers if @helpers
  helpers = Object.new
  helpers.extend SomeHelper
  helpers.extend AnotherHelper
  @helpers = helpers
end

@Spone
Copy link
Collaborator

Spone commented Sep 14, 2021

Hi @ramontayag! I think what @boardfish meant was:

class ExampleComponent < ViewComponent::Base
  include SomeHelper
  include AnotherHelper

  # ...
end

This makes the methods defined in SomeHelper and AnotherHelper accessible from your component.

You don't need to override the #helpers method, unless you want to keep your helpers namespaced within helpers.

@ramontayag
Copy link

Ah yes, definitely another way. Thank you @Spone

@boardfish
Copy link
Collaborator

@joelhawksley's original suggestion about setting a default has become relevant again as it'll be useful for the default use case in #1106, i.e. rendering components to broadcast via Turbo Streams in Active Record callbacks. What do folks think of using ActionController::Base.new.view_context as the default value for view_context? Anyone with deeper knowledge of Action View might be able to say if that has any wider repercussions.

@boardfish boardfish added active and removed stale labels Nov 2, 2021
@boardfish boardfish reopened this Nov 2, 2021
@ramontayag
Copy link

ramontayag commented Nov 19, 2021

FYI, in Rails 6.1 simply calling ActionView::Base.new won't work because it requires the args now.

This works for me:

ActionView::Base.new(ActionView::LookupContext.new([]), {}, nil)

@joelhawksley
Copy link
Member

joelhawksley commented Jul 30, 2024

👋 catching back up here, my official recommendation is:

ApplicationController.new.view_context.render(MyComponent.new)

I'm going to go ahead and close this issue. If anyone thinks we should add this recommendation to the docs, please reply with a comment ❤

@Spone
Copy link
Collaborator

Spone commented Jul 30, 2024

Yes, maybe we could add this over there? https://viewcomponent.org/guide/getting-started.html#rendering-from-controllers

@JamesChevalier
Copy link

👋 catching back up here, my official recommendation is:

ApplicationComponent.new.view_context.render(MyComponent.new)

I'm going to go ahead and close this issue. If anyone thinks we should add this recommendation to the docs, please reply with a comment ❤

I think I'm misunderstanding this recommendation, because I keep running into errors when I try to use it. Can someone help me understand this?

I just tried updating my code in an after_create_commit call in my model from:

after_create_commit -> {
  broadcast_prepend_to(
    "#{user_id}-activities",
    target: "activities",
    html: ApplicationController.render(ActivityComponent.new(activity: self), layout: false)
  )
}

to what I understand is the new suggested syntax:

after_create_commit -> {
  broadcast_prepend_to(
    "#{user_id}-activities",
    target: "activities",
    html: ApplicationComponent.new.view_context.render(ActivityComponent.new(activity: self), layout: false)
  )
}

This caused the error uninitialized constant Activity::ApplicationComponent
I tried prepending the call with :: like this:

after_create_commit -> {
  broadcast_prepend_to(
    "#{user_id}-activities",
    target: "activities",
    html: ::ApplicationComponent.new.view_context.render(ActivityComponent.new(activity: self), layout: false)
  )
}

but this resulted in a similar error uninitialized constant ApplicationComponent

I noticed I didn't have my own ApplicationComponent so I created one and set ActivityComponent to inherit from that (and removed the :: from my after_create_commit). The error it produced after doing this was private method 'view_context' called for an instance of ApplicationComponent
I also tried dropping my new ApplicationComponent and updating my after_create_commit to use html: ViewComponent::Base.new.view_context.render(ActivityComponent.new(activity: self), layout: false) but this produced (as I figured, since the underlying path would be the same) the same private method 'view_context' called for an instance of ViewComponent::Base error

Rails 7.1.4
ViewComponent 3.14.0

@joelhawksley
Copy link
Member

@JamesChevalier my apologies, that was a typo. It should be ApplicationController. I've updated the comment above ❤

@JamesChevalier
Copy link

@JamesChevalier my apologies, that was a typo. It should be ApplicationController. I've updated the comment above ❤

Thanks! 🫣 I found myself running into another issue, but it was definitely outside of the scope of ViewComponent. I'll explain below, to either help other people running into the same problem or to be corrected.

ApplicationController.new.view_context.render(ActivityComponent.new(activity: activity), layout: false)

Resulted in this error:

app/components/activity_component.html.erb:1:in `call': undefined method `host' for nil (NoMethodError)

        host: request.host,

I decided to try it with a partial, to check if it was ViewComponent-specific:

ApplicationController.new.view_context.render('activities/form', activity: Activity.first)

This resulted in the same error, with a little more context (it referred to url_for.rb#L39):

/Users/me/.asdf/installs/ruby/3.3.4/lib/ruby/gems/3.3.0/gems/actionpack-7.1.4/lib/action_controller/metal/url_for.rb:37:in `url_options': undefined method `host' for nil (ActionView::Template::Error)

This pointed out that I had no request, which makes sense. I did some research and came across ActionController::Renderer which suggests this syntax:

ApplicationController.renderer.render template: "posts/show", assigns: { post: Post.first }

This translated well into ViewComponent:

ApplicationController.renderer.render(ActivityComponent.new(activity: activity), layout: false)

@abuisman
Copy link

I had the issue that images uploaded with ActiveStorage weren't rendering properly when I broadcast them rendered with ApplicationController.renderer.render. I had to add url_for around the attachment references and then the proper hostname was used. Otherwise example.org was used as the host for images.

@anitsirc
Copy link

@joelhawksley

👋 catching back up here, my official recommendation is:

ApplicationController.new.view_context.render(MyComponent.new)
I'm going to go ahead and close this issue. If anyone thinks we should add this recommendation to the docs, please reply with a comment ❤

How does this hook up with the support for multiple formats?

I've tried using render as normally would, but that doesn't seem to work as it only renders html when used outside the controller. Use case is using view components to handle Mailer views

ApplicationController.new.view_context.render(plain: MyComponent.new)
ApplicationController.new.view_context.render(html: MyComponent.new)

Tried also hardcoding the value of format in my component

class MyComponent < ViewComponent::Base
  ....
  private
  
  def format
    :text
  end
end

ApplicationController.new.view_context.render(renderable: MyComponent.new) # Still renders html

Any pointers are welcome 😊

@joelhawksley
Copy link
Member

@anitsirc We are continuing to work to align our use of formats with Rails' logic. Can you try your test cases against #2085, which includes updates to the format logic?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
10 participants