studio-engine 0.74.11 → 0.74.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb7849240e659870dc37f51a2ba9c11c9307cab6059102805fcd5b4ef2bfdb53
4
- data.tar.gz: fdbb67edfb1d7fc445402f2449ebbca016f10f6a9927775b229f2a5ba8ce59d8
3
+ metadata.gz: c4fe6cb6d0167ad680d5d6e9dc577a623c87e83d839847be79950877b922d576
4
+ data.tar.gz: 07d63386f006d3bfba60556910097f0f0797e52f28f6bcde41e38c37e4af9326
5
5
  SHA512:
6
- metadata.gz: f7523343af9ecf3d2762c223b82ff777a850a84c29ddb52eaa6c09ae8e90d7588ba4011cbee361bf0f08c043ddf0081abdab36fafc4ae39caa2b4d59eb4309a0
7
- data.tar.gz: 003cd9c09eaba85c4006d92434a1e4606462e170db49e29764b80d32593fbd75f10cd9a547654df8edd293c06a5c91872808bab14c3a6c385ff7cf78bae88047
6
+ metadata.gz: a46c78e389a2776d874522186127a1a16ae5bca04429ee3879a3bd0b1a8ce4e669d382803d94a2f3becb127c8735cd40255f62ef848cdfec3a76e912997d347d
7
+ data.tar.gz: caab23ce4c8cd6788ad50bc28391302b9b388015dd33e19a80855ba708f335f38653a449af6afd3b4afe43dd92a7cd87233ed51874f831c7cf175d6109ebe76e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.74.12 — 2026-09-16
8
+
9
+ ### Fixed
10
+
11
+ - **A modal partial rendered from a layout no longer re-emits the whole page
12
+ body.** In a Rails partial `block_given?` is always true, and a `yield` with
13
+ no caller block returns the layout's content: the entire rendered page once
14
+ the layout is rendering. Four partials tested their block that way.
15
+ turf-monster hit it live: its layout-rendered username modal reaches
16
+ `blocks/_card_header` with a nil subtitle, so every signed-in page for a user
17
+ allowed to rename carried a second copy of its own body inside a
18
+ `<template>`. Measured on turf-monster's `/`, `/contests` and `/account`
19
+ against 0.74.11: two copies of the page body before, one after.
20
+ - New `Studio::PartialBlock.content(view, yielded)` returns a caller's block
21
+ content, or nil when there is none. It treats a blank yield, or a yield
22
+ equal to `content_for(:layout)`, as no block.
23
+ - `blocks/_card_header` (the subtitle slot), `blocks/_success_card` (both
24
+ slot positions), `_host` and `_scoped_host` (the registration seam) now
25
+ ask it instead of `block_given?`.
26
+ - The block contract is unchanged. A caller passing a non-blank block
27
+ renders as before, so no consumer edits a call site. A no-block render
28
+ outside a layout now omits the empty subtitle paragraph or slot wrapper it
29
+ used to leave.
30
+ - Guarded by `test/views/partial_block_layout_yield_test.rb`, which renders
31
+ each partial through a real layout and counts the page body.
32
+
7
33
  ## 0.74.11 — 2026-09-13
8
34
 
9
35
  ## 0.74.10 — 2026-09-13
@@ -838,8 +838,13 @@
838
838
  :class="$store.modals.cardClasses()">
839
839
  <%# Consumer-provided content registrations. Each block typically
840
840
  contains a <template x-if="$store.modals.current().id === 'X'">
841
- render "modals/X" </template>. %>
842
- <%= yield if block_given? %>
841
+ render "modals/X" </template>.
842
+ NOT `yield if block_given?`. In a partial block_given? is always true,
843
+ and a caller-less yield returns the layout's content, so a host
844
+ rendered from a layout with no block (an app whose modals all live in
845
+ _host_extras) put the whole page body inside the card. See
846
+ Studio::PartialBlock. %>
847
+ <%= Studio::PartialBlock.content(self, yield) %>
843
848
 
844
849
  <%# APP-WIDE modal registrations — the second seam. The block above is
845
850
  per-CALLSITE: an app rendering this host from two layouts (a live one
@@ -364,8 +364,11 @@
364
364
  @keydown.escape.window="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()"
365
365
  @click.self="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()">
366
366
  <div class="<%= card_class %> max-h-[85dvh] overflow-y-auto" :class="$store.<%= scoped_store %>.cardClasses()">
367
- <%# Consumer-provided registrations — one <template x-if> per modal id. %>
368
- <%= yield if block_given? %>
367
+ <%# Consumer-provided registrations — one <template x-if> per modal id.
368
+ Not `yield if block_given?`: a partial's block_given? is always true,
369
+ and a caller-less yield returns the layout's whole page body. See
370
+ Studio::PartialBlock. %>
371
+ <%= Studio::PartialBlock.content(self, yield) %>
369
372
  </div>
370
373
  </div>
371
374
  </template>
@@ -21,10 +21,16 @@
21
21
  for processing / loading states.
22
22
  size: :md (default) OR :lg (celebration end-state, text-3xl title).
23
23
 
24
- Block: if given, the block's content replaces the subtitle area. Explicit
25
- subtitle locals are checked BEFORE block_given? — a partial's block_given? can
26
- read TRUE off the layout yield even with no block passed (see the engine's
27
- Rails-partial block_given? note), so lead with the locals.
24
+ Block: if given, the block's content replaces the subtitle area. The subtitle
25
+ locals outrank it.
26
+
27
+ NEVER TEST THE BLOCK WITH block_given?. In a Rails partial it is always true,
28
+ and a yield with no caller block returns the LAYOUT's content. This branch
29
+ used to read `elsif block_given?`, so a header rendered from the layout with
30
+ no subtitle yielded the whole page body into the modal: every signed-in
31
+ turf-monster page for a user allowed to rename carried two copies of itself
32
+ (modal-header-yields-whole-page, 2026-09-16). Studio::PartialBlock
33
+ (lib/studio/partial_block.rb) tells a real block from that fallback.
28
34
  %>
29
35
  <%
30
36
  icon_color = local_assigns[:icon_color] || 'primary'
@@ -77,7 +83,7 @@
77
83
  <p class="<%= sub_cls %>" x-text="<%= subtitle_key %>"></p>
78
84
  <% elsif local_assigns[:subtitle] %>
79
85
  <p class="<%= sub_cls %>"><%= subtitle %></p>
80
- <% elsif block_given? %>
81
- <p class="<%= sub_cls %>"><%= yield %></p>
86
+ <% elsif (caller_block = Studio::PartialBlock.content(self, yield)) %>
87
+ <p class="<%= sub_cls %>"><%= caller_block %></p>
82
88
  <% end %>
83
89
  </div>
@@ -73,6 +73,11 @@
73
73
  large_title = !!local_assigns[:large_title]
74
74
  use_drain = has_redirect && !!local_assigns[:cta_drain]
75
75
  slot_position = local_assigns.fetch(:slot_position, :below_cta).to_sym
76
+ # The caller's block, captured ONCE, or nil when there is none. Never
77
+ # block_given?: in a partial it is always true, and a caller-less yield
78
+ # returns the layout's content, so a card rendered from a layout with no
79
+ # block re-emitted the whole page body under its CTA. See Studio::PartialBlock.
80
+ caller_block = Studio::PartialBlock.content(self, yield)
76
81
 
77
82
  data_attr = "{
78
83
  _remaining: #{redirect_secs},
@@ -231,8 +236,8 @@
231
236
  card has always run its kickoff timer and seeds bar above the button for
232
237
  that reason, and this is what lets the engine's card do the same instead of
233
238
  the app forking one to keep the order. %>
234
- <% if block_given? && slot_position == :above_cta %>
235
- <div class="mb-4"><%= yield %></div>
239
+ <% if caller_block && slot_position == :above_cta %>
240
+ <div class="mb-4"><%= caller_block %></div>
236
241
  <% end %>
237
242
 
238
243
  <%# Primary CTA — three flavors:
@@ -288,9 +293,9 @@
288
293
  <% end %>
289
294
 
290
295
  <%# ...or below it, which stays the DEFAULT: every existing caller renders here
291
- and none of them asked to move. A partial can only yield once, so exactly
292
- one of these two branches runs. %>
293
- <% if block_given? && slot_position != :above_cta %>
294
- <div class="mt-3"><%= yield %></div>
296
+ and none of them asked to move. The block is captured once at the top, and
297
+ exactly one of these two branches renders it. %>
298
+ <% if caller_block && slot_position != :above_cta %>
299
+ <div class="mt-3"><%= caller_block %></div>
295
300
  <% end %>
296
301
  </div>
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The content of the block a CALLER passed to a partial, or nil when it passed
5
+ # none. The engine's slot-taking partials ask this, never `block_given?`.
6
+ #
7
+ # WHY block_given? IS THE WRONG QUESTION IN A PARTIAL. ActionView compiles a
8
+ # template into a method and ALWAYS invokes that method with a block:
9
+ # PartialRenderer passes `{ |*name| view._layout_for(*name, &block) }` whether
10
+ # or not the render that reached the partial carried one. So `block_given?` is
11
+ # true in every partial. A `yield` with no caller block then falls through to
12
+ # `_layout_for`'s other branch, the LAYOUT's content. While the page's own
13
+ # template renders, that is empty. Once the layout renders, it is THE WHOLE
14
+ # PAGE BODY.
15
+ #
16
+ # MEASURED 2026-09-16 (modal-header-yields-whole-page). turf-monster renders
17
+ # the modal host from its layout. The username modal's plain "Saved" card
18
+ # reached blocks/_card_header with a nil subtitle, and the header's
19
+ # `elsif block_given?` yielded the entire page a second time inside a
20
+ # <template>: two copies of the page body on /, /contests and /account for
21
+ # every signed-in user allowed to rename. Nothing painted, nothing raised, and
22
+ # every request returned 200.
23
+ #
24
+ # HOW THIS ANSWERS IT. Rails gives a template no way to ask whether its caller
25
+ # passed a block, so the partial yields and hands the result in:
26
+ #
27
+ # caller_block = Studio::PartialBlock.content(self, yield)
28
+ #
29
+ # A caller-less yield can only return the layout's content or nothing, so a
30
+ # yield equal to `content_for(:layout)` is that fallback, not a block. A blank
31
+ # yield also reads as no block: an empty slot wrapper is never what a caller
32
+ # meant.
33
+ #
34
+ # THIS FIXES THE PREDICATE; THE BLOCK CONTRACT IS UNCHANGED. A caller that
35
+ # passes a non-blank block renders exactly as before, so no consumer edits a
36
+ # call site. A NEW slot should still prefer a named local that names a partial
37
+ # (solana-studio's wallet picker does), which needs no predicate at all.
38
+ module PartialBlock
39
+ module_function
40
+
41
+ # view — the partial's view context (`self` inside the template).
42
+ # yielded — the partial's own `yield`, evaluated where the partial calls this.
43
+ def content(view, yielded)
44
+ return nil if yielded.blank?
45
+ return nil if yielded == view.content_for(:layout)
46
+
47
+ yielded
48
+ end
49
+ end
50
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.11"
2
+ VERSION = "0.74.12"
3
3
  end
data/lib/studio.rb CHANGED
@@ -10,6 +10,7 @@ require "studio/theme_resolver"
10
10
  require "studio/ui_primitives"
11
11
  require "studio/js_literal"
12
12
  require "studio/js_identifier"
13
+ require "studio/partial_block"
13
14
  require "studio/sidebar_sections"
14
15
  require "studio/profile_sections"
15
16
  require "studio/profile_image"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.74.11
4
+ version: 0.74.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -570,6 +570,7 @@ files:
570
570
  - lib/studio/name_parts.rb
571
571
  - lib/studio/newsletter.rb
572
572
  - lib/studio/oauth_identity.rb
573
+ - lib/studio/partial_block.rb
573
574
  - lib/studio/profile_image.rb
574
575
  - lib/studio/profile_sections.rb
575
576
  - lib/studio/redis.rb