studio-engine 0.141.0 → 0.141.1

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: fdbbb3a4ed38791b34c2acda907501f647b1574dd58c3ae462ebb75a480f20ac
4
- data.tar.gz: cd7e60599fd6f9c56896532aa50ab3af7e2032ea0b5b947419f3cfd9609fb642
3
+ metadata.gz: e3ccc2c2e35d236c2709dc7a7918d2a1357d81e23e4a4ed31ae1e6a8a3fee555
4
+ data.tar.gz: 9af1f5d8ab55210882a7c1ad7e0ff8cc871e3938f853de749307c07ce8bfd9bd
5
5
  SHA512:
6
- metadata.gz: 6c4cd34992a2181995c6e111ea61eeef896c210e277a0d6a96ee9842d710af16e401698ac8935684f98ff02a76b100e3be2c48c1f052ec4ee1e2bde2ff405335
7
- data.tar.gz: 8bb445755ac725f8455454499ab6987428898e76db15214da7ef930338054954f85b9f0803fe2c3cf3416c592928087cecbe2b994525231265be633acf06bdb1
6
+ metadata.gz: 1c7fc9c56b2bd6bc48d4e1f595a2aa4f884b7c18cd3f61a9170164d1d24f19873810026a91a4ab1b8d022ac1c0e1d4965a042b4fc5194782d70d011c5604215c
7
+ data.tar.gz: 8e2724c527406edbc340d96783a3f2194acc3a6008fd530f12f2f4c7c585eba56516fa76d105b1aee2cccf8c0a7c0c6477229691b09f7640052d8a6b35516ec9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.141.1 — 2026-11-09
8
+
9
+ ### Fixed
10
+
11
+ - **A pointer resting on Log out no longer signs the user out.** Turbo 8 prefetches a link the pointer rests on for 100ms, and a prefetch of `GET /logout` ran the sign-out. The link sidebar's Log out now carries `data-turbo-prefetch="false"`; the boot's `studio/sign_out_prefetch` cancels Turbo's prefetch of any same-origin `/logout` link; and `Studio::ErrorHandling` answers a prefetched sign-out (the engine's `sessions#destroy` or a host's own) with `204` and `no-store`, without writing the session. `/logout` answers `DELETE` too, for a host that draws Log out with `button_to`. `Studio.prefetch_request?(request)` is public. A host that copied the hub's `sign_out_prefetch.js` or `refuse_prefetched_sign_out` can drop them after this release. See `docs/DEVICE_SESSIONS.md`, "Prefetch".
12
+ - **A morph refresh no longer draws a closed link sidebar open.** A page refresh by morph (a same-URL redirect under `turbo-refresh-method` morph, such as the hub's `/build` queued page after an admin claim) set the panel's style back to the server's and dropped the `display:none` Alpine had set, while `$store.sidebars.linkTreeOpen` stayed false. `studio/link_sidebar` now refuses the morph of a link sidebar panel's `style`. No host change.
13
+
7
14
  ## 0.141.0 — 2026-11-09
8
15
 
9
16
  ### Changed
@@ -26,6 +26,9 @@ module Studio
26
26
  # First, ahead of every host filter: a revoked device session is signed out
27
27
  # before anything reads current_user (docs/DEVICE_SESSIONS.md).
28
28
  prepend_before_action :end_revoked_device_session
29
+ # Ahead of that: a prefetched sign-out is answered before any filter reads or
30
+ # writes the session (docs/DEVICE_SESSIONS.md, "Prefetch").
31
+ prepend_before_action :refuse_prefetched_sign_out, if: :sign_out_action?
29
32
  before_action :require_authentication
30
33
  after_action :track_user_session
31
34
 
@@ -35,6 +38,28 @@ module Studio
35
38
 
36
39
  private
37
40
 
41
+ # The sign-out action: the engine's SessionsController#destroy, or a host's
42
+ # own SessionsController that replaces it (Turf's). Both answer /logout.
43
+ def sign_out_action?
44
+ controller_path == "sessions" && action_name == "destroy"
45
+ end
46
+
47
+ # A speculative fetch never signs anyone out. Turbo 8 prefetches a link the
48
+ # pointer rests on for 100ms (X-Sec-Purpose: prefetch), and a GET /logout
49
+ # prefetch ran the sign-out: its Set-Cookie dropped the session while the page
50
+ # on screen still drew the user (hub task build-funnel-held-message-cause).
51
+ # The answer is 204 and no-store, with the session left unwritten: no cookie is
52
+ # sent, the device session stays live, and no after_action runs. The Log out
53
+ # link opts out of prefetch too (components/_link_sidebar), so this answers
54
+ # only a prefetch that still arrives.
55
+ def refuse_prefetched_sign_out
56
+ return unless Studio.prefetch_request?(request)
57
+
58
+ response.headers["Cache-Control"] = "no-store"
59
+ request.session_options[:skip] = true
60
+ head :no_content
61
+ end
62
+
38
63
  # The signed-in person, not whoever an admin is impersonating. A signed-out
39
64
  # request stops at the cookie: it runs no query it would not otherwise run. A
40
65
  # request must not fail because its bookkeeping did.
@@ -21,6 +21,7 @@ import { installAlpineShims } from "studio/alpine_shims"
21
21
  import { installStudioLoader } from "studio/loader"
22
22
  import "studio/reload_visits"
23
23
  import "studio/in_page_links"
24
+ import "studio/sign_out_prefetch"
24
25
 
25
26
  installAlpineShims()
26
27
  startPinnedStack()
@@ -31,6 +31,15 @@
31
31
  // click that lands before Alpine has bound it. They are installed once, on
32
32
  // every page, and do nothing where no link sidebar is rendered.
33
33
  //
34
+ // A MORPH KEEPS THE PANEL'S STATE. A page refresh by morph (turbo-refresh-method
35
+ // morph: a same-URL redirect, or Turbo.visit(location.href, { action: "replace" }))
36
+ // sets every attribute back to the server's. The server's style has no display, so
37
+ // Idiomorph stripped the display:none Alpine had put on a closed panel; the flag did
38
+ // not change, so x-show never ran again, and the panel was drawn open while
39
+ // linkTreeOpen read false (the hub's /build queued page, task
40
+ // engine-logout-never-prefetched). keepPanelDisplay refuses the morph of a panel's
41
+ // style, so the display Alpine set stays.
42
+ //
34
43
  // WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
35
44
  // its own nonced module tag as well as through the boot graph, like
36
45
  // studio/alpine_stores. It imports nothing, and
@@ -84,6 +93,17 @@ export function linkSidebarStore(Alpine) {
84
93
  return Alpine.store("sidebars") || null
85
94
  }
86
95
 
96
+ // The turbo:before-morph-attribute listener: a link sidebar panel keeps its style
97
+ // (Alpine's display for x-show) through a morph. Every other attribute morphs.
98
+ export function keepPanelDisplay(event) {
99
+ const target = event && event.target
100
+ if (!target || typeof target.matches !== "function") return false
101
+ if (!event.detail || event.detail.attributeName !== "style") return false
102
+ if (!target.matches(LINK_SIDEBAR_PANELS)) return false
103
+ event.preventDefault()
104
+ return true
105
+ }
106
+
87
107
  export function closeLinkSidebar(Alpine) {
88
108
  const sidebars = linkSidebarStore(Alpine)
89
109
  if (sidebars && sidebars.linkTreeOpen) sidebars.linkTreeOpen = false
@@ -174,6 +194,7 @@ export function installLinkSidebar({ win, doc, now = () => new Date() } = {}) {
174
194
  })
175
195
  // A page restored from Turbo's cache or the bfcache comes back closed.
176
196
  root.addEventListener("turbo:before-cache", () => closeLinkSidebar(host.Alpine))
197
+ root.addEventListener("turbo:before-morph-attribute", keepPanelDisplay)
177
198
  host.addEventListener("pageshow", (event) => {
178
199
  if (!event.persisted) return
179
200
  closeLinkSidebar(host.Alpine)
@@ -0,0 +1,41 @@
1
+ // studio/sign_out_prefetch: Turbo never prefetches a sign-out (docs/DEVICE_SESSIONS.md,
2
+ // "Prefetch").
3
+ //
4
+ // Turbo 8 prefetches a same-origin link the pointer rests on for 100ms. Every host's
5
+ // sign-out is GET /logout, so a pointer that only passed over a Log out link signed the
6
+ // browser out: the prefetch ran SessionsController#destroy and its Set-Cookie dropped
7
+ // the session, while the page on screen still drew the user. Measured in the hub's
8
+ // e2e/build_funnel.spec.js (task build-funnel-held-message-cause).
9
+ //
10
+ // The engine's own Log out (components/_link_sidebar) carries data-turbo-prefetch="false".
11
+ // This listener covers a Log out link a host draws without it. The server answers any
12
+ // prefetch that still arrives with a 204 (Studio::ErrorHandling#refuse_prefetched_sign_out),
13
+ // so a click is always a real visit and never a cached 204.
14
+ //
15
+ // It imports nothing; test/javascript/sign_out_prefetch.test.mjs loads it as a data: module.
16
+
17
+ // The paths whose GET ends the session (the engine's `match "logout"`).
18
+ export const SIGN_OUT_PATHS = Object.freeze([ "/logout" ])
19
+
20
+ // PURE. Whether `link` (anything with getAttribute("href")) signs out on `origin`.
21
+ export function isSignOutLink(link, origin) {
22
+ const href = link && typeof link.getAttribute === "function" ? link.getAttribute("href") : null
23
+ if (!href) return false
24
+ let url
25
+ try {
26
+ url = new URL(href, origin)
27
+ } catch (_error) {
28
+ return false
29
+ }
30
+ return url.origin === origin && SIGN_OUT_PATHS.includes(url.pathname.replace(/\/+$/, "") || "/")
31
+ }
32
+
33
+ // The turbo:before-prefetch listener: cancels the prefetch of a sign-out link.
34
+ export function refuseSignOutPrefetch(event, origin = globalThis.location?.origin) {
35
+ if (isSignOutLink(event.target, origin)) event.preventDefault()
36
+ }
37
+
38
+ if (typeof document !== "undefined" && !globalThis.__studioSignOutPrefetch) {
39
+ globalThis.__studioSignOutPrefetch = true
40
+ document.addEventListener("turbo:before-prefetch", (event) => refuseSignOutPrefetch(event))
41
+ }
@@ -157,7 +157,10 @@
157
157
  <% footer_html = capture do %>
158
158
  <% if logged_in? %>
159
159
  <div class="border-t border-subtle p-2"<% if glass %> data-sidebar-glass-foot<% end %>>
160
+ <%# Never prefetched: Turbo 8 fetches a link the pointer rests on, and a GET
161
+ /logout fetched is a sign-out (docs/DEVICE_SESSIONS.md, "Prefetch"). %>
160
162
  <%= link_to logout_path,
163
+ data: { turbo_prefetch: "false" },
161
164
  class: "flex items-center gap-3 rounded-md px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink transition",
162
165
  "@click": close_action do %>
163
166
  <% if glass %><span class="flex-shrink-0" data-sidebar-row-icon><%= studio_icon("log-out") %></span><% else %><span>👋</span><% end %><span>Log out</span>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.141.0"
2
+ VERSION = "0.141.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -2135,6 +2135,17 @@ module Studio
2135
2135
  ProfileSections.resolve(profile_sections, view, page: page)
2136
2136
  end
2137
2137
 
2138
+ # The purpose headers a speculative fetch carries: Turbo's X-Sec-Purpose, the
2139
+ # browser's Sec-Purpose (link prefetch, speculation rules) and the older
2140
+ # Purpose. A prefetch must not act: Studio::ErrorHandling refuses a prefetched
2141
+ # sign-out with it, and a host can ask it of any other GET that writes.
2142
+ PREFETCH_PURPOSE = /\bprefetch\b/i
2143
+ PREFETCH_HEADERS = %w[X-Sec-Purpose Sec-Purpose Purpose].freeze
2144
+
2145
+ def self.prefetch_request?(request)
2146
+ PREFETCH_HEADERS.any? { |name| request.headers[name].to_s.match?(PREFETCH_PURPOSE) }
2147
+ end
2148
+
2138
2149
  def self.env_truthy?(value)
2139
2150
  %w[1 true yes on].include?(value.to_s.strip.downcase)
2140
2151
  end
@@ -2154,7 +2165,10 @@ module Studio
2154
2165
 
2155
2166
  post "sso_continue", to: "sessions#sso_continue"
2156
2167
  get "sso_login", to: "sessions#sso_login"
2157
- get "logout", to: "sessions#destroy"
2168
+ # Sign-out answers GET (every host's Log out link) and DELETE (a button_to
2169
+ # form). A prefetch of either is refused before the action runs
2170
+ # (Studio::ErrorHandling#refuse_prefetched_sign_out).
2171
+ match "logout", to: "sessions#destroy", via: %i[get delete], as: :logout
2158
2172
  get "signup", to: "registrations#new"
2159
2173
  post "signup", to: "registrations#create"
2160
2174
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.141.0
4
+ version: 0.141.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -574,6 +574,7 @@ files:
574
574
  - app/javascript/studio/profile_form.js
575
575
  - app/javascript/studio/pull_to_refresh.js
576
576
  - app/javascript/studio/reload_visits.js
577
+ - app/javascript/studio/sign_out_prefetch.js
577
578
  - app/javascript/studio/sticky_table_header.js
578
579
  - app/javascript/studio/stimulus.js
579
580
  - app/javascript/studio/studio_confetti.js