active_storage-async_variants 0.3.1 → 0.4.0

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: ed31f4592e3d865609aef29b42657856321528d5013bb2a1ec499e3de93c5bb4
4
- data.tar.gz: 994460c1ae175245da3d71489b25d4143827931f926289c94c18957df8cf0610
3
+ metadata.gz: 003cbfe68ded6c238669546679e76b7d0c8e856842450181ceb8497a9e452361
4
+ data.tar.gz: 0b41dad0aa8a59e81e9b2e384d6d04e55ee479c0c35822d71e3a06a46b0c64cd
5
5
  SHA512:
6
- metadata.gz: c6b94095e095899175ac9fa37497500b9947e761d2ed07a7fdd78d55a90ac05898dca086e02d8b29419fc1fde8567628a4f6c7444470826ae6fb0556220af4ef
7
- data.tar.gz: 109f1670b0ad22ce670bf27751ffeac462f0f10c94d0d89f4777c211b2fb1aa179c3981373ebc9b2f36a2262c4722dcdf73cd54ff95ca29cf6b035765150357f
6
+ metadata.gz: e5eb0728baf9f4d9cfc591625f6035d362d9df192c2c64fb7cef4e397fcef9817a460dbfba24210f01d777360a1d2e73e4dabf7d00306f8c9685ff1df4641b0e
7
+ data.tar.gz: 4c87c749a9dad93f251fcddf5830996f53d7c054becabdd529ea89000eb75e5f2360b6332aea764fc615931b0c350a15bfe8610d4b5efa1561a22ffd3d966ee6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [0.4.0]
2
+
3
+ - **Breaking:** Replaced the polling-`<img>` architecture with a `<turbo-frame>` for unprocessed variants. `image_tag`/`video_tag` with `async: true` now emits a normal `<img>`/`<video>` when the variant is already processed, and a `<turbo-frame src="…">` otherwise. The frame hits a new gem-shipped endpoint (`GET /active_storage/async_variants/states/:signed_blob_id/:variation_key`) that renders one of `_processing`/`_failed`/`_processed` partials. Non-terminal renders include an inline `<script>` that schedules the frame's next reload, so the state polls itself until it terminates.
4
+ - New `app/views/active_storage/async_variants/states/` partial set — apps can override any partial by creating a same-named file in their own `app/views/active_storage/async_variants/states/`. The processing state defaults to a bare `<img>`/`<video>` pointing at the variant's URL, which the gem redirects to the app's configured `processing:` SVG.
5
+ - All CSS and JavaScript ship inline in the per-response state partials — no asset-pipeline footprint. Apps don't need to include any stylesheet or script.
6
+ - **Breaking:** `Variant#processed` and `Preview#processed` are now no-ops on bucket-backed services (they previously also lazy-enqueued `ProcessJob` for any record that wasn't already processed). Enqueue now happens only at attachment time (via `AttachmentExtension#transform_variants_later`). Pre-existing blobs that missed the auto-enqueue won't self-heal on first view; backfill via a rake task.
7
+ - New public `#enqueue!` method on both `Variant` and `Preview` with the same signature — no `respond_to?` dance. Creates a pending `VariantRecord` (RecordNotUnique guards dedupe) and dispatches `ProcessJob`. Replaces the previously private `enqueue_processing` / `enqueue_async_preview`.
8
+ - The named-variant lookup now walks one level back through `preview_image` attachments, so a request for a Variant of a video's extracted preview frame can recover the named-variant declaration from the parent record's source-video field. Fixes nil-URL 500s in dev where the cold Registry can't resolve a redirect-controller request.
9
+ - Added `turbo-rails >= 2.0` as a runtime dependency.
10
+ - Removed `RepresentationsRedirectControllerExtension`, the `[data-async-variant-*]` polling JS, the bundled `app/assets/stylesheets/active_storage_async_variants.css`, the engine's asset precompile initializer, and the `apply_async_data!` helper machinery. Consumers depending on those data attributes must migrate to targeting the new `.async-variant-state` classes or override the partials.
11
+
1
12
  ## [0.3.1]
2
13
 
3
14
  - Bound the stored variant `error` to 16k chars at both write sites (the failed-status callback and `ProcessJob`'s rescue). An external transformer reporting a >64KB error payload was overflowing the `TEXT` column and 500ing the callback, leaving the variant stuck instead of marked failed.
data/README.md CHANGED
@@ -97,29 +97,9 @@ ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"
97
97
 
98
98
  The resulting URL is `"#{cdn_host}/#{variant.key}"`.
99
99
 
100
- ### Wiring the JavaScript
100
+ ### JavaScript
101
101
 
102
- The gem ships a dependency-free Stimulus-like controller at `app/assets/javascripts/active_storage_async_variants.js`. It is registered with the asset pipeline by the engine but not auto-imported. Wire it up the same way as `@rails/activestorage` -- pick whichever fits your bundler:
103
-
104
- Using **importmap-rails**:
105
-
106
- ```ruby
107
- # config/importmap.rb
108
- pin "@active_storage/async_variants", to: "active_storage_async_variants.js", preload: true
109
- ```
110
-
111
- ```js
112
- // app/javascript/application.js
113
- import "@active_storage/async_variants"
114
- ```
115
-
116
- Using **the asset pipeline** (sprockets/propshaft) with a classic `javascript_include_tag`:
117
-
118
- ```erb
119
- <%= javascript_include_tag "active_storage_async_variants" %>
120
- ```
121
-
122
- The module auto-starts on `DOMContentLoaded` and finds elements with `data-async-variant-state-value`. Opt out by setting `window.ActiveStorageAsyncVariants = null` before the script loads, then calling `start()` yourself from the exported module when ready.
102
+ No manual wiring is required. The async state partials are self-contained `<turbo-frame>`s. The only requirement is that the host app loads **Turbo** -- the gem depends on `turbo-rails`, which a default Rails app already includes.
123
103
 
124
104
  ## Writing a Transformer
125
105
 
data/Rakefile CHANGED
@@ -5,4 +5,12 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- task default: :spec
8
+ desc "Run browser-level acceptance tests (cucumber + cuprite against spec/dummy)"
9
+ task :cucumber do
10
+ sh "bundle exec cucumber"
11
+ end
12
+
13
+ desc "Run rspec and cucumber"
14
+ task all: [:spec, :cucumber]
15
+
16
+ task default: :all
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ class StatesController < ActiveStorage::AsyncVariants.parent_controller.constantize
6
+ # signed_blob_id + variation_key in URL act as CSRF: both require app's secret.
7
+ skip_forgery_protection
8
+
9
+ helper "turbo/frames"
10
+ helper ActiveStorage::AsyncVariants::Helper
11
+
12
+ layout false
13
+
14
+ before_action :set_variant
15
+
16
+ def show
17
+ end
18
+
19
+ helper_method :async_variant_kind, :async_variant_direct?, :async_variant_html_options
20
+
21
+ private
22
+
23
+ def set_variant
24
+ blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
25
+ variation = ActiveStorage::Variation.decode(params[:variation_key])
26
+ @variant = blob.variant(variation)
27
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
28
+ head :not_found
29
+ end
30
+
31
+ def async_variant_kind
32
+ params[:kind].to_s == "video" ? :video : :image
33
+ end
34
+
35
+ def async_variant_direct?
36
+ ActiveModel::Type::Boolean.new.cast(params[:direct])
37
+ end
38
+
39
+ def async_variant_html_options
40
+ params[:opts]
41
+ &.permit(*ActiveStorage::AsyncVariants::PASS_THROUGH_HTML_OPTIONS)
42
+ .to_h
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,4 @@
1
+ <div class="async-variant-state async-variant-failed">
2
+ <% method = kind == :video ? :video_tag : :image_tag %>
3
+ <%= send method, async_variant_representation_path(variant), **html_options.symbolize_keys %>
4
+ </div>
@@ -0,0 +1 @@
1
+ <%= render "active_storage/async_variants/states/processing", local_assigns %>
@@ -0,0 +1,5 @@
1
+ <%
2
+ method = kind == :video ? :video_tag : :image_tag
3
+ src = direct ? async_variant_direct_url(variant) : async_variant_representation_path(variant)
4
+ %>
5
+ <%= send method, src, **html_options.symbolize_keys %>
@@ -0,0 +1,10 @@
1
+ <div class="async-variant-state async-variant-processing">
2
+ <% method = kind == :video ? :video_tag : :image_tag %>
3
+ <%= send method, async_variant_representation_path(variant), **html_options.symbolize_keys %>
4
+ </div>
5
+ <%# Self-perpetuating poll: Turbo runs <script>s in frame swaps, so each
6
+ pending/processing response schedules its own next reload. Terminal
7
+ partials (_failed, _processed) emit no script -> chain stops. %>
8
+ <script>
9
+ setTimeout(() => document.getElementById("<%= async_variant_frame_id(variant) %>")?.reload(), 3000)
10
+ </script>
@@ -0,0 +1,8 @@
1
+ <%= turbo_frame_tag async_variant_frame_id(@variant) do %>
2
+ <%= render "active_storage/async_variants/states/#{@variant.async_state}", {
3
+ variant: @variant,
4
+ html_options: async_variant_html_options,
5
+ kind: async_variant_kind,
6
+ direct: async_variant_direct?,
7
+ } %>
8
+ <% end %>
data/config/routes.rb CHANGED
@@ -4,4 +4,8 @@ Rails.application.routes.draw do
4
4
  post "/active_storage/async_variants/callbacks/:token",
5
5
  to: "active_storage/async_variants/callbacks#create",
6
6
  as: :active_storage_async_variant_callback
7
+
8
+ get "/active_storage/async_variants/states/:signed_blob_id/:variation_key",
9
+ to: "active_storage/async_variants/states#show",
10
+ as: :async_variant_state
7
11
  end
@@ -0,0 +1,21 @@
1
+ Feature: image_tag with async: true emits the right markup per variant state
2
+
3
+ Scenario: A processed variant emits a plain <img> -- no turbo-frame, no chrome
4
+ Given a user with an attached avatar
5
+ And the avatar's :thumb_proc variant is in processed state
6
+ When I visit the avatar page for the :thumb_proc variant
7
+ Then the page should NOT contain a turbo-frame
8
+
9
+ Scenario: An unprocessed variant emits a turbo-frame in processing state
10
+ Given a user with an attached avatar
11
+ And the avatar's :thumb_proc variant is in processing state
12
+ When I visit the avatar page for the :thumb_proc variant
13
+ Then the page should contain a turbo-frame
14
+ And the frame should render the processing state
15
+
16
+ Scenario: A failed variant renders the failed partial inside the frame
17
+ Given a user with an attached avatar
18
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
19
+ When I visit the avatar page for the :thumb_proc variant
20
+ Then the page should contain a turbo-frame
21
+ And the frame should render the failed state
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ Given /^a user with an attached avatar$/ do
4
+ @user = User.create!
5
+ attach_avatar_to(@user)
6
+ end
7
+
8
+ Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed) state(?: with error "([^"]+)")?$/ do |variant_name, state, error|
9
+ variant = @user.avatar.variant(variant_name.to_sym)
10
+ if state == "processed"
11
+ simulate_processed_variant(variant)
12
+ else
13
+ create_variant_record(variant, state: state, error: error)
14
+ end
15
+ end
16
+
17
+ Given /^the retry affordance is visible to everyone$/ do
18
+ ActiveStorage::AsyncVariants.retry_visible_if { true }
19
+ end
20
+
21
+ Given /^the retry affordance is hidden$/ do
22
+ ActiveStorage::AsyncVariants.retry_visible_if { false }
23
+ end
24
+
25
+ Given /^the retry affordance requires a signed-in user$/ do
26
+ ActiveStorage::AsyncVariants.retry_visible_if { current_user.present? }
27
+ end
28
+
29
+ When /^I sign in as the (admin|user)$/ do |_kind|
30
+ visit "/session/#{@user.id}"
31
+ end
32
+
33
+ When /^I visit the avatar page for the :(\w+) variant$/ do |variant_name|
34
+ visit "/avatars/#{@user.id}/#{variant_name}"
35
+ end
36
+
37
+ Then /^the page should contain a turbo-frame$/ do
38
+ expect(page).to have_css("turbo-frame")
39
+ end
40
+
41
+ Then /^the page should NOT contain a turbo-frame$/ do
42
+ expect(page).to have_no_css("turbo-frame")
43
+ expect(page).to have_css("img")
44
+ end
45
+
46
+ Then /^the frame should render the (failed|processing|processed) state$/ do |state|
47
+ expect(page).to have_css("turbo-frame .async-variant-#{state}")
48
+ end
49
+
50
+ Then /^the retry affordance should be visible$/ do
51
+ expect(page).to have_css("async-variant-retry", visible: :all)
52
+ end
53
+
54
+ Then /^the retry affordance should NOT be visible$/ do
55
+ expect(page).to have_no_css("async-variant-retry", visible: :all)
56
+ end
57
+
58
+ Then /^the page should not have navigated away$/ do
59
+ expect(page).to have_css("h1#page-marker")
60
+ end
61
+
62
+ Then /^the failure dialog should be open$/ do
63
+ expect(page).to have_css("async-variant-retry", visible: :all)
64
+ open = page.evaluate_script(<<~JS)
65
+ (() => {
66
+ const host = document.querySelector("async-variant-retry")
67
+ if (!host || !host.shadowRoot) return false
68
+ const dialog = host.shadowRoot.querySelector("dialog")
69
+ return dialog ? dialog.open : false
70
+ })()
71
+ JS
72
+ expect(open).to eq(true)
73
+ end
74
+
75
+ Then /^the failure dialog should contain "([^"]+)"$/ do |text|
76
+ contains = page.evaluate_script(<<~JS)
77
+ (() => {
78
+ const host = document.querySelector("async-variant-retry")
79
+ if (!host || !host.shadowRoot) return false
80
+ return host.shadowRoot.textContent.includes(#{text.inspect})
81
+ })()
82
+ JS
83
+ expect(contains).to eq(true)
84
+ end
85
+
86
+ # Wait for Turbo to swap the frame and our shim to attach the shadow root.
87
+ def wait_for_shadow!
88
+ Timeout.timeout(5) do
89
+ loop do
90
+ attached = page.evaluate_script("!!document.querySelector('async-variant-retry')?.shadowRoot")
91
+ break if attached
92
+ sleep 0.05
93
+ end
94
+ end
95
+ end
96
+
97
+ When /^I click the retry opener$/ do
98
+ wait_for_shadow!
99
+ page.evaluate_script(<<~JS)
100
+ document.querySelector("async-variant-retry").shadowRoot.querySelector(".opener").click()
101
+ JS
102
+ end
103
+
104
+ When /^I click "Retry processing"$/ do
105
+ wait_for_shadow!
106
+ page.evaluate_script(<<~JS)
107
+ document.querySelector("async-variant-retry").shadowRoot.querySelector("footer button").click()
108
+ JS
109
+ end
110
+
111
+ When /^I close the failure dialog$/ do
112
+ wait_for_shadow!
113
+ page.evaluate_script(<<~JS)
114
+ document.querySelector("async-variant-retry").shadowRoot.querySelector("dialog > button[type=button]").click()
115
+ JS
116
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["RAILS_ENV"] ||= "test"
4
+ require File.expand_path("../../spec/dummy/config/environment", __dir__)
5
+
6
+ require "capybara"
7
+ require "capybara/cucumber"
8
+ require "capybara/cuprite"
9
+ require_relative "../../spec/support/schema"
10
+
11
+ DummySchema.load!
12
+
13
+ Capybara.register_driver(:cuprite) do |app|
14
+ Capybara::Cuprite::Driver.new(app, window_size: [1200, 900], timeout: 10, process_timeout: 20, js_errors: true)
15
+ end
16
+ Capybara.app = Rails.application
17
+ Capybara.javascript_driver = :cuprite
18
+ Capybara.default_driver = :cuprite
19
+ Capybara.server = :puma, { Silent: true }
20
+
21
+ ActionController::Base.allow_forgery_protection = false
22
+
23
+ # Test adapter just records enqueued jobs -- we don't want them to actually run
24
+ # during browser tests (they'd call real transformers and modify variant_records
25
+ # behind the test's back). The retry step seeds the new state explicitly.
26
+ ActiveJob::Base.queue_adapter = :test
27
+
28
+ Before { DummySchema.cleanup! }
@@ -2,76 +2,61 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
+ # Prepended onto ActionView::Helpers::AssetTagHelper. Adds `async:` and
6
+ # `direct:` options to image_tag/video_tag; routes unprocessed variants
7
+ # through a <turbo-frame> whose body lives in StatesController#show.
5
8
  module AssetTagHelperExtension
9
+ include ActiveStorage::AsyncVariants::Helper
10
+
6
11
  def image_tag(source, options = {})
7
- options = options.symbolize_keys
8
- async = options.delete(:async)
9
- direct = options.delete(:direct)
10
- return super if !async && !direct
11
- variant = AssetTagHelperExtension.coerce_variant!(source)
12
- src = AssetTagHelperExtension.resolve_src(variant, direct: direct)
13
- AssetTagHelperExtension.apply_async_data!(options, variant: variant, direct: direct) if async
14
- super(src, options)
12
+ async_variant_tag(:image, [source], options) do |urls, opts|
13
+ super(urls.first, opts)
14
+ end
15
15
  end
16
16
 
17
17
  def video_tag(*sources)
18
- options = sources.extract_options!.symbolize_keys
19
- async = options.delete(:async)
20
- direct = options.delete(:direct)
21
- return super(*sources, options) if !async && !direct
22
- variant = AssetTagHelperExtension.coerce_variant!(sources.first)
23
- sources[0] = AssetTagHelperExtension.resolve_src(variant, direct: direct)
24
- AssetTagHelperExtension.apply_async_data!(options, variant: variant, direct: direct) if async
25
- super(*sources, options)
18
+ options = sources.extract_options!
19
+ async_variant_tag(:video, sources, options) do |urls, opts|
20
+ super(*urls, opts)
21
+ end
26
22
  end
27
23
 
28
- class << self
29
- def coerce_variant!(source)
30
- unless source.is_a?(ActiveStorage::VariantWithRecord) || source.is_a?(ActiveStorage::Preview)
31
- raise ArgumentError, "image_tag/video_tag with async:/direct: requires an ActiveStorage::VariantWithRecord or Preview, got #{source.class}"
32
- end
33
- source
34
- end
24
+ private
35
25
 
36
- def resolve_src(variant, direct:)
37
- if direct && variant.async_state == "processed"
38
- direct_url(variant)
39
- else
40
- # Idempotent: enqueue ProcessJob if no record exists yet so a
41
- # never-touched variant doesn't sit pending forever. Gated on
42
- # bucket-backed services -- on Disk/Test the gem defers to
43
- # vanilla ActiveStorage, and #processed would run a synchronous
44
- # transform (e.g. vips on an mp4) that the external transformer
45
- # is supposed to handle.
46
- variant.processed if variant.blob.bucket_backed?
47
- polymorphic_url(variant)
48
- end
49
- end
26
+ def async_variant_tag(kind, sources, options)
27
+ options = options.symbolize_keys
28
+ async = options.delete(:async)
29
+ direct = options.delete(:direct)
30
+ return yield(sources, options) if !async && !direct
50
31
 
51
- def apply_async_data!(options, variant:, direct:)
52
- data = (options[:data] || {}).symbolize_keys
53
- controllers = data[:controller].to_s.split
54
- controllers << "async-variant" unless controllers.include?("async-variant")
55
- data[:controller] = controllers.join(" ")
56
- data[:async_variant_src_value] = polymorphic_url(variant)
57
- data[:async_variant_state_value] = variant.async_state
58
- data[:async_variant_direct_value] = direct_url(variant) if direct && variant.async_state == "processed"
59
- options[:data] = data
60
- end
32
+ variant, *rest = sources
33
+ assert_async_variant!(variant)
61
34
 
62
- def direct_url(variant)
63
- if cdn = ActiveStorage::AsyncVariants.cdn_host
64
- "#{cdn}/#{variant.key}"
65
- else
66
- variant.image.url
35
+ if async && !async_variant_processed_inline?(variant)
36
+ async_variant_turbo_frame(variant, kind:, direct:, html_options: options) do
37
+ # populate TurboFrame with a placeholder image/video, so there's valid markup right away
38
+ yield [async_variant_representation_path(variant), *rest], options.except(:data)
67
39
  end
40
+ else
41
+ yield [async_variant_resolved_src(variant, direct:), *rest], options
68
42
  end
43
+ end
69
44
 
70
- def polymorphic_url(variant)
71
- url_options = ActiveStorage::Current.url_options || Rails.application.routes.default_url_options
72
- Rails.application.routes.url_helpers.polymorphic_url(variant, **url_options)
45
+ def assert_async_variant!(source)
46
+ unless source.is_a?(ActiveStorage::VariantWithRecord) || source.is_a?(ActiveStorage::Preview)
47
+ raise ArgumentError, "image_tag/video_tag with async:/direct: requires an ActiveStorage::VariantWithRecord or Preview, got #{source.class}"
73
48
  end
74
49
  end
50
+
51
+ def async_variant_turbo_frame(variant, kind:, direct:, html_options:, &block)
52
+ content_tag(
53
+ :"turbo-frame",
54
+ id: async_variant_frame_id(variant),
55
+ src: async_variant_frame_src(variant, kind: kind, direct: direct, html_options: html_options),
56
+ refresh: "morph",
57
+ &block
58
+ )
59
+ end
75
60
  end
76
61
  end
77
62
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ # View/controller helper methods. Included in the AssetTagHelperExtension
6
+ # (so image_tag/video_tag can use them) and added to StatesController via
7
+ # `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
8
+ # use them).
9
+ module Helper
10
+ # In test with non-bucket-backed services, the gem defers to vanilla
11
+ # ActiveStorage (synchronous vips transform) -- inline rendering keeps
12
+ # those environments simple. Otherwise, only inline a normal <img> when
13
+ # the variant has reached the processed terminal state.
14
+ def async_variant_processed_inline?(variant)
15
+ !variant.blob.bucket_backed? || variant.async_state == "processed"
16
+ end
17
+
18
+ def async_variant_resolved_src(variant, direct:)
19
+ if direct && variant.async_state == "processed"
20
+ async_variant_direct_url(variant)
21
+ else
22
+ variant.processed if variant.blob.bucket_backed?
23
+ async_variant_representation_path(variant)
24
+ end
25
+ end
26
+
27
+ def async_variant_frame_id(variant)
28
+ digest = variant.variation.digest.gsub(/[^a-zA-Z0-9_-]/, "")
29
+ "async-variant-#{variant.blob.id}-#{digest}"
30
+ end
31
+
32
+ def async_variant_frame_src(variant, kind:, direct:, html_options: {})
33
+ async_variant_state_path(
34
+ signed_blob_id: variant.blob.signed_id,
35
+ variation_key: variant.variation.key,
36
+ kind:,
37
+ direct:,
38
+ opts: html_options.slice(*PASS_THROUGH_HTML_OPTIONS),
39
+ )
40
+ end
41
+
42
+ def async_variant_direct_url(variant)
43
+ if cdn = ActiveStorage::AsyncVariants.cdn_host
44
+ "#{cdn}/#{variant.key}"
45
+ else
46
+ variant.image.url
47
+ end
48
+ end
49
+
50
+ def async_variant_representation_path(variant)
51
+ Rails.application.routes.url_helpers.rails_blob_representation_path(
52
+ variant.blob.signed_id,
53
+ variant.variation.key,
54
+ variant.blob.filename.to_s,
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
@@ -3,18 +3,27 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  module PreviewExtension
6
- # Enqueue (or no-op if already done) the same ProcessJob the
7
- # VariantWithRecord path uses, so Preview-side and VariantWithRecord-side
8
- # processing converge on a single record-and-job machinery rather than
9
- # the gem's earlier two-path design (one writing to preview_image's
10
- # variant_records, one to the original blob's).
6
+ # Block vanilla ActiveStorage's synchronous preview transform on
7
+ # async-backed services; rely on the auto-enqueue (AttachmentExtension)
8
+ # path -- and the public #enqueue! -- to dispatch ProcessJob.
11
9
  def processed
12
- if async_preview?
13
- enqueue_async_preview unless preview_variant_processed?
14
- self
15
- else
16
- super
10
+ async_preview? ? self : super
11
+ end
12
+
13
+ def enqueue!
14
+ if result = find_named_async_variant
15
+ attachment, variant_name, _ = result
16
+
17
+ blob.variant_records.create!(
18
+ variation_digest: variation.digest,
19
+ state: "pending",
20
+ )
21
+ ActiveStorage::AsyncVariants::ProcessJob.perform_later(
22
+ attachment.record, attachment.name, variant_name.to_s,
23
+ )
17
24
  end
25
+ rescue ActiveRecord::RecordNotUnique
26
+ # another caller (or a leftover record) wins; their job handles it
18
27
  end
19
28
 
20
29
  def processed?
@@ -52,14 +61,29 @@ module ActiveStorage
52
61
  # Variations rebuilt from the redirect URL only carry transformations --
53
62
  # :transformer / :processing / :failed are stripped at Variation#initialize
54
63
  # and not embedded in the URL key. Recover them via the digest-keyed
55
- # registry that VariationExtension warms on every view-side variant call.
64
+ # registry that VariationExtension warms on every view-side variant call,
65
+ # or fall back to scanning attached named variants when the registry is
66
+ # cold (autoloader hasn't touched the consumer model yet).
56
67
  def resolved_async_options
57
68
  @resolved_async_options ||=
58
69
  variation.async_options.presence ||
59
70
  ActiveStorage::AsyncVariants::Registry[variation.digest] ||
71
+ find_named_async_variant&.dig(2) ||
60
72
  {}
61
73
  end
62
74
 
75
+ def find_named_async_variant
76
+ target = variation.transformations.to_json
77
+ blob.attachments.each do |attachment|
78
+ attachment.send(:named_variants).each do |name, _|
79
+ candidate = attachment.variant(name.to_sym)
80
+ next unless candidate.variation.transformations.to_json == target
81
+ return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:transformer].present?
82
+ end
83
+ end
84
+ nil
85
+ end
86
+
63
87
  def preview_variant_processed?
64
88
  find_preview_variant_record&.state == "processed"
65
89
  end
@@ -71,27 +95,6 @@ module ActiveStorage
71
95
  blob.variant_records.find_by(variation_digest: variation.digest)
72
96
  end
73
97
 
74
- # Delegate to the named-variant VariantWithRecord so we go through the
75
- # exact same enqueue_processing + ProcessJob machinery as direct
76
- # variant calls. Skips silently if no matching named variant exists,
77
- # which can happen for raw transformations that don't correspond to
78
- # any declared async variant. Also skipped on non-bucket services,
79
- # where the gem defers to vanilla ActiveStorage and dispatching here
80
- # would synchronously transform via vips (broken for video blobs).
81
- def enqueue_async_preview
82
- return unless blob.bucket_backed?
83
- target = variation.transformations.to_json
84
- blob.attachments.each do |attachment|
85
- attachment.send(:named_variants).each do |name, _|
86
- candidate = attachment.variant(name.to_sym)
87
- next unless candidate.variation.transformations.to_json == target
88
- next unless candidate.variation.async_options[:processing].present?
89
- candidate.processed
90
- return
91
- end
92
- end
93
- end
94
-
95
98
  def fallback_preview_url(...)
96
99
  case active_fallback
97
100
  when :original then blob.url(...)
@@ -3,13 +3,27 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  module VariantWithRecordExtension
6
+ # Block vanilla ActiveStorage's synchronous transform on bucket-backed
7
+ # services; rely on the auto-enqueue (AttachmentExtension) path -- and the
8
+ # public #enqueue! -- to dispatch ProcessJob.
6
9
  def processed
7
- if blob.bucket_backed?
8
- enqueue_processing unless processed? || processing?
9
- self
10
- else
11
- super
10
+ blob.bucket_backed? ? self : super
11
+ end
12
+
13
+ def enqueue!
14
+ if result = find_named_async_variant
15
+ attachment, variant_name, _ = result
16
+
17
+ blob.variant_records.create!(
18
+ variation_digest: variation.digest,
19
+ state: "pending",
20
+ )
21
+ ActiveStorage::AsyncVariants::ProcessJob.perform_later(
22
+ attachment.record, attachment.name, variant_name.to_s,
23
+ )
12
24
  end
25
+ rescue ActiveRecord::RecordNotUnique
26
+ # another caller (or a leftover record) wins; their job handles it
13
27
  end
14
28
 
15
29
  def url(...)
@@ -62,6 +76,7 @@ module ActiveStorage
62
76
  @resolved_async_options ||=
63
77
  variation.async_options.presence ||
64
78
  ActiveStorage::AsyncVariants::Registry[variation.digest] ||
79
+ find_named_async_variant&.dig(2) ||
65
80
  {}
66
81
  end
67
82
 
@@ -73,33 +88,29 @@ module ActiveStorage
73
88
  end
74
89
  end
75
90
 
76
- def enqueue_processing
77
- result = find_named_async_variant
78
- return unless result
79
- attachment, variant_name, _ = result
80
-
81
- return if async_record
82
-
83
- blob.variant_records.create!(
84
- variation_digest: variation.digest,
85
- state: "pending",
86
- )
87
-
88
- ActiveStorage::AsyncVariants::ProcessJob.perform_later(
89
- attachment.record, attachment.name, variant_name.to_s,
90
- )
91
- rescue ActiveRecord::RecordNotUnique
92
- # another caller won the race; their job will handle processing
93
- end
94
-
95
- # Cold-path scan: only used by enqueue_processing, which needs the
96
- # (attachment.record, attachment.name, variant_name) tuple to dispatch
97
- # ProcessJob -- more than the digest registry stores. Hot-path URL
98
- # resolution goes through Registry, not this method.
91
+ # Cold-path scan: used by enqueue! (which needs the attachment +
92
+ # variant_name to dispatch ProcessJob) and by resolved_async_options as
93
+ # a fallback when the Registry is cold (e.g. in dev, when a
94
+ # RepresentationsRedirectController request hits a worker that hasn't
95
+ # autoloaded the consumer model yet).
96
+ #
97
+ # Walks one level through preview_image attachments so a request for
98
+ # a Variant of a video's extracted preview frame can still find the
99
+ # named variant declared on the parent record's source-video field.
99
100
  def find_named_async_variant
100
101
  target = variation.transformations.to_json
102
+ scan_for_named_variant(blob, target)
103
+ end
104
+
105
+ def scan_for_named_variant(blob_to_scan, target, depth: 0)
106
+ blob_to_scan.attachments.each do |attachment|
107
+ if attachment.name == "preview_image" && attachment.record_type == "ActiveStorage::Blob" && depth < 1
108
+ source = ActiveStorage::Blob.find_by(id: attachment.record_id)
109
+ result = source && scan_for_named_variant(source, target, depth: depth + 1)
110
+ return result if result
111
+ next
112
+ end
101
113
 
102
- blob.attachments.each do |attachment|
103
114
  attachment.send(:named_variants).each do |name, _|
104
115
  candidate = attachment.variant(name.to_sym)
105
116
  if candidate.variation.transformations.to_json == target
@@ -107,7 +118,6 @@ module ActiveStorage
107
118
  end
108
119
  end
109
120
  end
110
-
111
121
  nil
112
122
  end
113
123
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "turbo-rails"
3
4
  require_relative "async_variants/version"
5
+ require_relative "async_variants/helper"
4
6
  require_relative "async_variants/transformer"
5
7
  require_relative "async_variants/registry"
6
8
  require_relative "async_variants/blob_extension"
@@ -11,22 +13,22 @@ require_relative "async_variants/preview_extension"
11
13
  require_relative "async_variants/attachment_extension"
12
14
  require_relative "async_variants/reflection_extension"
13
15
  require_relative "async_variants/process_job"
14
- require_relative "async_variants/representations_redirect_controller_extension"
15
16
  require_relative "async_variants/asset_tag_helper_extension"
16
17
 
17
18
  module ActiveStorage
18
19
  module AsyncVariants
20
+ # HTML attributes round-tripped through the state-endpoint URL so the
21
+ # eventual processed-state render can apply them to the inner <img>/<video>.
22
+ PASS_THROUGH_HTML_OPTIONS = %i[alt width height controls autoplay preload].freeze
23
+
19
24
  mattr_accessor :cdn_host
20
25
 
21
- class Engine < ::Rails::Engine
22
- # :nocov:
23
- initializer "active_storage_async_variants.assets" do |app|
24
- if app.config.respond_to?(:assets)
25
- app.config.assets.precompile += %w[active_storage_async_variants.js]
26
- end
27
- end
28
- # :nocov:
26
+ # Lets the host app plug its auth chain (and thus `current_user`) into the
27
+ # gem's StatesController. Set to a string so resolution is deferred until
28
+ # the host's class is autoloadable. Defaults to ActionController::Base.
29
+ mattr_accessor :parent_controller, default: "ActionController::Base"
29
30
 
31
+ class Engine < ::Rails::Engine
30
32
  # Prepend the core model/reflection extensions before eager_load runs
31
33
  # so that models' has_X_attached blocks (and the Variation.wrap calls
32
34
  # they trigger via reflection.variant) go through our hooks. The
@@ -46,9 +48,6 @@ module ActiveStorage
46
48
  # the first one loads.
47
49
  ActiveStorage::AsyncVariants.prepend_model_extensions!
48
50
 
49
- ActiveStorage::Representations::RedirectController.prepend(
50
- ActiveStorage::AsyncVariants::RepresentationsRedirectControllerExtension
51
- )
52
51
  ActionView::Helpers::AssetTagHelper.prepend(
53
52
  ActiveStorage::AsyncVariants::AssetTagHelperExtension
54
53
  )
@@ -56,9 +55,6 @@ module ActiveStorage
56
55
  end
57
56
 
58
57
  def self.prepend_model_extensions!
59
- return if @model_extensions_prepended
60
- @model_extensions_prepended = true
61
-
62
58
  require "active_storage/reflection"
63
59
  ActiveStorage::Reflection::HasAttachedReflection.prepend(
64
60
  ActiveStorage::AsyncVariants::ReflectionExtension
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_storage-async_variants
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-29 00:00:00.000000000 Z
10
+ date: 2026-06-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activestorage
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: turbo-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
26
40
  email:
27
41
  - micah@botandrose.com
28
42
  executables: []
@@ -38,9 +52,17 @@ files:
38
52
  - LICENSE.txt
39
53
  - README.md
40
54
  - Rakefile
41
- - app/assets/javascripts/active_storage_async_variants.js
42
55
  - app/controllers/active_storage/async_variants/callbacks_controller.rb
56
+ - app/controllers/active_storage/async_variants/states_controller.rb
57
+ - app/views/active_storage/async_variants/states/_failed.html.erb
58
+ - app/views/active_storage/async_variants/states/_pending.html.erb
59
+ - app/views/active_storage/async_variants/states/_processed.html.erb
60
+ - app/views/active_storage/async_variants/states/_processing.html.erb
61
+ - app/views/active_storage/async_variants/states/show.html.erb
43
62
  - config/routes.rb
63
+ - features/state_rendering.feature
64
+ - features/step_definitions/dummy_steps.rb
65
+ - features/support/env.rb
44
66
  - gemfiles/rails_7.2.gemfile
45
67
  - gemfiles/rails_8.0.gemfile
46
68
  - gemfiles/rails_8.1.gemfile
@@ -48,11 +70,11 @@ files:
48
70
  - lib/active_storage/async_variants/asset_tag_helper_extension.rb
49
71
  - lib/active_storage/async_variants/attachment_extension.rb
50
72
  - lib/active_storage/async_variants/blob_extension.rb
73
+ - lib/active_storage/async_variants/helper.rb
51
74
  - lib/active_storage/async_variants/preview_extension.rb
52
75
  - lib/active_storage/async_variants/process_job.rb
53
76
  - lib/active_storage/async_variants/reflection_extension.rb
54
77
  - lib/active_storage/async_variants/registry.rb
55
- - lib/active_storage/async_variants/representations_redirect_controller_extension.rb
56
78
  - lib/active_storage/async_variants/transformer.rb
57
79
  - lib/active_storage/async_variants/variant_record_extension.rb
58
80
  - lib/active_storage/async_variants/variant_with_record_extension.rb
@@ -1,169 +0,0 @@
1
- // active-storage-async_variants
2
- // Renders the correct fallback for async ActiveStorage variants, and polls
3
- // for completion. Pairs with the gem's image_tag/video_tag `async:` /
4
- // `direct:` options.
5
-
6
- const STATE_ATTR = "data-async-variant-state-value"
7
- const SRC_ATTR = "data-async-variant-src-value"
8
- const DIRECT_ATTR = "data-async-variant-direct-value"
9
- const SELECTOR = `[${STATE_ATTR}]`
10
- const HEADER = "X-Async-Variant-State"
11
- const POLLABLE_STATES = ["pending", "processing"]
12
- const POLL_BASE_MS = 3000
13
- const MAX_POLLS = 10
14
- const RETRY_BASE_MS = 10000
15
- const MAX_RETRIES = 3
16
-
17
- const isSafari = typeof navigator !== "undefined" &&
18
- /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
19
-
20
- const elementState = new WeakMap()
21
-
22
- function stateFor(el) {
23
- let s = elementState.get(el)
24
- if (!s) {
25
- s = { pollTimer: null, pollCount: 0, retries: 0 }
26
- elementState.set(el, s)
27
- }
28
- return s
29
- }
30
-
31
- function srcUrl(el) { return el.getAttribute(SRC_ATTR) }
32
- function directUrl(el) { return el.getAttribute(DIRECT_ATTR) }
33
- function variantState(el) { return el.getAttribute(STATE_ATTR) }
34
-
35
- async function fetchAsyncState(url) {
36
- if (!url) return null
37
- try {
38
- const response = await fetch(url, { method: "HEAD", redirect: "manual", cache: "no-store" })
39
- if (response.type === "opaqueredirect") return null
40
- return response.headers.get(HEADER)
41
- } catch {
42
- return null
43
- }
44
- }
45
-
46
- function schedulePoll(el) {
47
- const s = stateFor(el)
48
- s.pollTimer = setTimeout(() => poll(el), POLL_BASE_MS * Math.pow(2, s.pollCount))
49
- }
50
-
51
- function stopPolling(el) {
52
- const s = elementState.get(el)
53
- if (s && s.pollTimer) {
54
- clearTimeout(s.pollTimer)
55
- s.pollTimer = null
56
- }
57
- }
58
-
59
- function startPolling(el) {
60
- const s = stateFor(el)
61
- if (s.pollTimer) return
62
- s.pollCount = 0
63
- schedulePoll(el)
64
- }
65
-
66
- async function poll(el) {
67
- const state = await fetchAsyncState(srcUrl(el))
68
- const s = stateFor(el)
69
- s.pollTimer = null
70
- if (POLLABLE_STATES.includes(state)) {
71
- s.pollCount += 1
72
- if (s.pollCount < MAX_POLLS) schedulePoll(el)
73
- return
74
- }
75
- if (state === "failed") {
76
- el.setAttribute(STATE_ATTR, "failed")
77
- return
78
- }
79
- const target = directUrl(el) || srcUrl(el)
80
- if (!target) return
81
- el.setAttribute(STATE_ATTR, "processed")
82
- el.src = target + (target.includes("?") ? "&" : "?") + "_t=" + Date.now()
83
- }
84
-
85
- function fallback(el) {
86
- const url = srcUrl(el)
87
- if (url) el.setAttribute("src", url)
88
- }
89
-
90
- function onLoad(event) {
91
- const el = event.target
92
- if (!el.matches || !el.matches(SELECTOR)) return
93
- if (POLLABLE_STATES.includes(variantState(el))) startPolling(el)
94
- }
95
-
96
- function onError(event) {
97
- const el = event.target
98
- if (!el.matches || !el.matches(SELECTOR)) return
99
- const s = stateFor(el)
100
- if (s.retries >= MAX_RETRIES) return
101
- setTimeout(() => fallback(el), RETRY_BASE_MS * Math.pow(2, s.retries))
102
- s.retries += 1
103
- }
104
-
105
- function setupVideo(el) {
106
- if (el.nodeName !== "VIDEO") return
107
- if (isSafari) fallback(el)
108
- // Browser autoplay-via-attribute is permitted on first load, but the
109
- // attribute also makes Turbo's cloneNode(true) snapshot start playback on
110
- // the detached clone (ghost audio). Strip the attribute after insertion --
111
- // playback is already scheduled -- and rely on play() here for restored
112
- // snapshots (which won't have the attribute anymore).
113
- // https://github.com/hotwired/turbo/issues/1017
114
- if (el.hasAttribute("autoplay")) {
115
- el.removeAttribute("autoplay")
116
- const promise = el.play()
117
- if (promise && typeof promise.catch === "function") promise.catch(() => {})
118
- }
119
- }
120
-
121
- function processAdded(node) {
122
- if (node.nodeType !== 1) return
123
- if (node.matches && node.matches(SELECTOR)) setupVideo(node)
124
- if (node.querySelectorAll) node.querySelectorAll(SELECTOR).forEach(setupVideo)
125
- }
126
-
127
- function processRemoved(node) {
128
- if (node.nodeType !== 1) return
129
- if (node.matches && node.matches(SELECTOR)) {
130
- stopPolling(node)
131
- elementState.delete(node)
132
- }
133
- if (node.querySelectorAll) {
134
- node.querySelectorAll(SELECTOR).forEach(el => {
135
- stopPolling(el)
136
- elementState.delete(el)
137
- })
138
- }
139
- }
140
-
141
- let started = false
142
- function start() {
143
- if (started || typeof document === "undefined") return
144
- started = true
145
- document.addEventListener("load", onLoad, true)
146
- document.addEventListener("error", onError, true)
147
- const observer = new MutationObserver(records => {
148
- for (const r of records) {
149
- r.addedNodes.forEach(processAdded)
150
- r.removedNodes.forEach(processRemoved)
151
- }
152
- })
153
- observer.observe(document.documentElement, { childList: true, subtree: true })
154
- document.querySelectorAll(SELECTOR).forEach(setupVideo)
155
- }
156
-
157
- function autostart() {
158
- if (typeof window === "undefined" || window.ActiveStorageAsyncVariants !== null) start()
159
- }
160
-
161
- if (typeof document !== "undefined") {
162
- if (document.readyState === "loading") {
163
- document.addEventListener("DOMContentLoaded", autostart)
164
- } else {
165
- setTimeout(autostart, 1)
166
- }
167
- }
168
-
169
- export { start }
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveStorage
4
- module AsyncVariants
5
- module RepresentationsRedirectControllerExtension
6
- ASYNC_HEADER = "X-Async-Variant-State"
7
- INTERCEPT_STATES = %w[pending processing failed].freeze
8
-
9
- def show
10
- state = @representation.async_state
11
- if INTERCEPT_STATES.include?(state)
12
- fallback = @representation.url(disposition: params[:disposition])
13
- if fallback.is_a?(String) && fallback.start_with?("/")
14
- path = Rails.public_path.join(fallback.delete_prefix("/"))
15
- if File.exist?(path)
16
- response.set_header(ASYNC_HEADER, state)
17
- response.set_header("Cache-Control", "no-store, private")
18
- return send_file path, disposition: "inline"
19
- end
20
- end
21
- end
22
- super
23
- end
24
- end
25
- end
26
- end