Skip to content

Conversation

@thewatts
Copy link

@thewatts thewatts commented Dec 20, 2025

What are you trying to accomplish?

Fix translation scope resolution in deeply nested component blocks (3+ levels). Currently, translations called inside deeply nested slot blocks using renders_many/renders_one incorrectly resolve to an intermediate component's scope instead of the partial's scope where the block was defined.

Example of the bug:

<!-- In app/views/shared/_action_menu_panel.html.erb -->
<%= render ActionMenuComponent.new do |menu| %>
  <% menu.with_list do |list| %>
    <% list.with_item do %>
      <%= t(".menu_action") %>  <!-- Should resolve to shared.action_menu_panel.menu_action -->
    <% end %>
  <% end %>
<% end %>

The translation incorrectly resolves to action_list_component.menu_action instead of shared.action_menu_panel.menu_action.

Builds on #2389.
Fixes #2386.

What approach did you choose and why?

The previous fix in #2389 added with_original_virtual_path, which restored the virtual path one level up to the immediate parent component.

This worked for 2-level nesting:

<!-- Partial → Component → Block (2 levels) -->
<%= render MyComponent.new do %>
  <%= t(".title") %>  <!-- Works correctly -->
<% end %>

But failed for 3+ level nesting:

<!-- Partial → Component1 → Component2 → Block (3 levels) -->
<%= render ActionMenuComponent.new do |menu| %>
  <% menu.with_list do |list| %>
    <% list.with_item do %>
      <%= t(".menu_action") %>  <!-- Resolved to wrong scope -->
    <% end %>
  <% end %>
<% end %>

The issue: The method only went one level up to the immediate parent, not all the way back to the original partial.

Approach: Capture the virtual path at block definition time instead of trying to walk back up the component hierarchy at execution time.

Implementation details:

  1. In slotable.rb (line 397): When a block is provided to a slot method (e.g., with_item), capture the current @virtual_path and store it on the slot as __vc_content_block_virtual_path
  2. In slot.rb (lines 61, 71): When executing the block, call @parent.with_captured_virtual_path(@__vc_content_block_virtual_path) to temporarily restore the captured path
  3. In base.rb (line 384): The with_captured_virtual_path method takes the captured path as an explicit parameter and temporarily sets the virtual path during block execution

Why this approach:

  • Works at any nesting depth (3, 5, 10+ levels) because the path is captured at definition time
  • Simpler than walking up a component hierarchy
  • Clear semantics: "Execute this block with the virtual path from where it was defined"

Alternative approaches considered:

  • Walking the parent chain at execution time: More complex, requires tracking parent references through the entire component tree
  • Global path tracking on view context: Would pollute view context and cause issues with multiple component trees in the same view

Tradeoffs:

  • Adds one instance variable (__vc_content_block_virtual_path) to each slot that has a block
  • Slight memory overhead, but necessary to maintain correct translation scope

After this fix, deeply nested translations work correctly:

<!-- Now works at any nesting depth -->
<%= render ActionMenuComponent.new do |menu| %>
  <% menu.with_list do |list| %>
    <% list.with_item do %>
      <%= t(".menu_action") %>  <!-- Correctly resolves to shared.action_menu_panel.menu_action -->
    <% end %>
  <% end %>
<% end %>

Anything you want to highlight for special attention from reviewers?

Test coverage: Added comprehensive tests for both 3-level and 5-level nesting scenarios using simplified test components inspired by Primer's ActionMenu pattern. The tests verify that translations resolve to the partial's scope, not any intermediate component's scope.

Implementation note: This also consolidates the virtual path restoration logic by having all three execution contexts (main component content blocks, lambda slots, and component class slots) use the same with_captured_virtual_path method with an explicit parameter, making the behavior consistent everywhere.

When translations are called inside deeply nested component blocks
(3+ levels) using renders_many/renders_one, they were incorrectly
resolving to an intermediate component's scope instead of the
partial's scope where the block was defined.

Example of the bug:

```erb
<!-- In app/views/shared/_action_menu_panel.html.erb -->
<%= render ActionMenuComponent.new do |menu| %>
  <% menu.with_list do |list| %>
    <% list.with_item do %>
      <%= t(".menu_action") %>  <!-- Should resolve to shared.action_menu_panel.menu_action -->
    <% end %>
  <% end %>
<% end %>
```

The translation would incorrectly resolve to `action_list_component.menu_action`
instead of `shared.action_menu_panel.menu_action`.

Previous Fix:

While a fix was added in ViewComponent#2389 to solve translation scope issues, the
`with_original_virtual_path` method only restored the virtual path
one level up to the immediate parent component, not to the original
partial where the block was defined.

This worked for 2-level nesting:

```erb
<!-- Partial → Component → Block (2 levels) -->
<%= render MyComponent.new do %>
  <%= t(".title") %>  <!-- Works correctly -->
<% end %>
```

But failed for 3+ level nesting:

```erb
<!-- Partial → Component1 → Component2 → Block (3 levels) -->
<%= render ActionMenuComponent.new do |menu| %>
  <% menu.with_list do |list| %>
    <% list.with_item do %>
      <%= t(".menu_action") %>  <!-- Resolved to wrong scope -->
    <% end %>
  <% end %>
<% end %>
```

Solution:

This change captures the virtual path at block definition time (when
`with_*` slot methods are called) and stores it on the slot. When the
block executes, it restores to the captured virtual path.

Implementation:

1. In slotable.rb: Capture `@virtual_path` when a block is provided to
   a slot method and store it as `__vc_content_block_virtual_path`
2. In slot.rb: When executing the block, call
   `@parent.with_captured_virtual_path(@__vc_content_block_virtual_path)`
   to restore the captured path
3. In base.rb: The `with_captured_virtual_path` method temporarily sets
   the virtual path to the captured value during block execution

After this fix, deeply nested translations work correctly:

```erb
<!-- Now works at any nesting depth -->
<%= render ActionMenuComponent.new do |menu| %>
  <% menu.with_list do |list| %>
    <% list.with_item do %>
      <%= t(".menu_action") %>  <!-- Correctly resolves to shared.action_menu_panel.menu_action -->
    <% end %>
  <% end %>
<% end %>
```

For test coverage: Tests for 3-level and 5-level nesting scenarios
using simplified test components inspired by Primer's ActionMenu pattern.

Builds on ViewComponent#2389.
Fixes ViewComponent#2386.
@thewatts thewatts force-pushed the nw/nested-partial-translations branch from f1c372b to 40d9242 Compare December 20, 2025 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v4] Cannot use rails translate within a component slot

1 participant