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 +4 -4
- data/CHANGELOG.md +7 -0
- data/app/controllers/concerns/studio/error_handling.rb +25 -0
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/link_sidebar.js +21 -0
- data/app/javascript/studio/sign_out_prefetch.js +41 -0
- data/app/views/components/_link_sidebar.html.erb +3 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +15 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3ccc2c2e35d236c2709dc7a7918d2a1357d81e23e4a4ed31ae1e6a8a3fee555
|
|
4
|
+
data.tar.gz: 9af1f5d8ab55210882a7c1ad7e0ff8cc871e3938f853de749307c07ce8bfd9bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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>
|
data/lib/studio/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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
|