active_storage-async_variants 0.1.0 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +0 -3
  3. data/CHANGELOG.md +21 -1
  4. data/CLAUDE.md +4 -4
  5. data/README.md +67 -27
  6. data/Rakefile +9 -1
  7. data/app/controllers/active_storage/async_variants/callbacks_controller.rb +34 -1
  8. data/app/controllers/active_storage/async_variants/states_controller.rb +46 -0
  9. data/app/views/active_storage/async_variants/states/_failed.html.erb +4 -0
  10. data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
  11. data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
  12. data/app/views/active_storage/async_variants/states/_processing.html.erb +10 -0
  13. data/app/views/active_storage/async_variants/states/show.html.erb +8 -0
  14. data/config/routes.rb +4 -0
  15. data/features/state_rendering.feature +21 -0
  16. data/features/step_definitions/dummy_steps.rb +116 -0
  17. data/features/support/env.rb +28 -0
  18. data/gemfiles/rails_7.2.gemfile +1 -0
  19. data/gemfiles/rails_8.0.gemfile +1 -0
  20. data/gemfiles/rails_8.1.gemfile +1 -0
  21. data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +62 -0
  22. data/lib/active_storage/async_variants/attachment_extension.rb +3 -1
  23. data/lib/active_storage/async_variants/blob_extension.rb +15 -0
  24. data/lib/active_storage/async_variants/helper.rb +59 -0
  25. data/lib/active_storage/async_variants/preview_extension.rb +120 -0
  26. data/lib/active_storage/async_variants/process_job.rb +6 -7
  27. data/lib/active_storage/async_variants/reflection_extension.rb +35 -0
  28. data/lib/active_storage/async_variants/registry.rb +38 -0
  29. data/lib/active_storage/async_variants/variant_record_extension.rb +29 -0
  30. data/lib/active_storage/async_variants/variant_with_record_extension.rb +88 -19
  31. data/lib/active_storage/async_variants/variation_extension.rb +2 -1
  32. data/lib/active_storage/async_variants/version.rb +1 -1
  33. data/lib/active_storage/async_variants.rb +66 -9
  34. metadata +32 -5
  35. data/gemfiles/rails_7.2.gemfile.lock +0 -269
  36. data/gemfiles/rails_8.0.gemfile.lock +0 -266
  37. data/gemfiles/rails_8.1.gemfile.lock +0 -269
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4395d49e7c04b9206145e2c8dbe0bf3f4466ed736054ec5e9fc3b8fc7dea3a1c
4
- data.tar.gz: 1cf4f1090be29b6ff6a7a50be05ecaa2bcc79f50de9a203ec26d99c9d8e58fc8
3
+ metadata.gz: 003cbfe68ded6c238669546679e76b7d0c8e856842450181ceb8497a9e452361
4
+ data.tar.gz: 0b41dad0aa8a59e81e9b2e384d6d04e55ee479c0c35822d71e3a06a46b0c64cd
5
5
  SHA512:
6
- metadata.gz: 1d696905633e0039f8489377d41ea120fd89d5fccfcadbd8872981ea916d9032395f5f66091eba170de40f03547b7b93c90457e6796f15868ae3e7d3ecf937c4
7
- data.tar.gz: 21c13be811dc6c6b9b19123f6ef8c9c23c1e2cbc0360ee9f79a88b4bf83d023f514d61964e75b749c2b073ad19e7db52f878a2b55de8c90b4018ff831a4d3f2d
6
+ metadata.gz: e5eb0728baf9f4d9cfc591625f6035d362d9df192c2c64fb7cef4e397fcef9817a460dbfba24210f01d777360a1d2e73e4dabf7d00306f8c9685ff1df4641b0e
7
+ data.tar.gz: 4c87c749a9dad93f251fcddf5830996f53d7c054becabdd529ea89000eb75e5f2360b6332aea764fc615931b0c350a15bfe8610d4b5efa1561a22ffd3d966ee6
@@ -14,9 +14,6 @@ jobs:
14
14
  matrix:
15
15
  ruby: ["3.3", "3.4", "4.0"]
16
16
  rails: ["rails-7.2", "rails-8.0", "rails-8.1"]
17
- exclude:
18
- - ruby: "4.0"
19
- rails: "rails-7.2"
20
17
  steps:
21
18
  - uses: actions/checkout@v4
22
19
  - uses: ruby/setup-ruby@v1
data/CHANGELOG.md CHANGED
@@ -1,4 +1,24 @@
1
- ## [Unreleased]
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
+
12
+ ## [0.3.1]
13
+
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.
15
+
16
+ ## [0.3.0]
17
+
18
+ - Touch attached records when a variant transitions to processed, so consumer caches keyed on `cache_key_with_version` invalidate without needing manual cascading. Multi-hop cascades remain the consumer's responsibility via standard Rails `touch:` or `after_touch`.
19
+ - Dedupe concurrent `.processed` calls so they enqueue at most one `ProcessJob` per blob+variation. Previously, every call before the first job flipped state to "processing" enqueued another job; each job created a fresh variant blob, leaving the others as orphans. The record is now created as `pending` at enqueue time, using the unique index on `active_storage_variant_records (blob_id, variation_digest)` (already shipped by the standard Active Storage migration) as the dedupe key. Once a record reaches `failed` (after `ProcessJob` exhausts its 3-attempt `retry_on` cycle), further `.processed` calls no longer re-enqueue — the variant is permanently failed.
20
+ - Renamed the `fallback:` variant option to `processing:` to better describe what it represents — the placeholder served while the variant is being processed.
21
+ - New `failed:` variant option that specifies a distinct placeholder to render when the variant has permanently failed, separate from the `processing:` placeholder. Accepts `:original`, `:blank`, a String URL, or a Proc that receives the blob. Defaults to `processing:` when unspecified. Also extends `processing:` to accept a String URL directly (previously you had to wrap a static URL in a Proc).
2
22
 
3
23
  ## [0.1.0] - 2026-03-03
4
24
 
data/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## What This Is
6
6
 
7
- A Rails engine gem that extends Active Storage with async-safe variant processing. Solves the problem where slow transformations (e.g., video transcoding) block requests or fail silently. The `fallback:` option on a variant definition opts it into async processing.
7
+ A Rails engine gem that extends Active Storage with async-safe variant processing. Solves the problem where slow transformations (e.g., video transcoding) block requests or fail silently. The `processing:` option on a variant definition opts it into async processing.
8
8
 
9
9
  ## Commands
10
10
 
@@ -19,9 +19,9 @@ bundle exec rspec spec/active_storage/async_variants_spec.rb -e "description" #
19
19
 
20
20
  The gem works by prepending extension modules onto Active Storage classes:
21
21
 
22
- - **`VariationExtension`** → `ActiveStorage::Variation` — extracts async options (`fallback:`, `transformer:`, `max_retries:`) from variant config before passing the rest to standard Active Storage
23
- - **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants with `fallback:`
24
- - **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve fallback while processing; adds state query methods (`ready?`, `processing?`, `pending?`, `failed?`)
22
+ - **`VariationExtension`** → `ActiveStorage::Variation` — extracts async options (`processing:`, `failed:`, `transformer:`) from variant config before passing the rest to standard Active Storage
23
+ - **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants with `processing:`
24
+ - **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve the `processing:` (or `failed:`) placeholder while not ready; adds state query methods (`processed?`, `processing?`, `pending?`, `failed?`)
25
25
  - **`ProcessJob`** — background job that determines transformer type (inline vs external) and processes accordingly
26
26
 
27
27
  ### Transformer Types
data/README.md CHANGED
@@ -19,7 +19,7 @@ bin/rails db:migrate
19
19
 
20
20
  ## Usage
21
21
 
22
- Add `fallback:` to any named variant to opt into the async pipeline:
22
+ Add `processing:` to any named variant to opt into the async pipeline. The value is what to serve while the variant is being processed:
23
23
 
24
24
  ```ruby
25
25
  class User < ApplicationRecord
@@ -28,12 +28,12 @@ class User < ApplicationRecord
28
28
  transformer: VideoTranscoder,
29
29
  codec: "vp9",
30
30
  resolution: "720p",
31
- fallback: :original
31
+ processing: :original
32
32
  end
33
33
  end
34
34
  ```
35
35
 
36
- The presence of `fallback:` is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The `transformer:` option is independent -- you can use a custom transformer synchronously, or use the default transformer asynchronously:
36
+ The presence of `processing:` is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The `transformer:` option is independent -- you can use a custom transformer synchronously, or use the default transformer asynchronously:
37
37
 
38
38
  ```ruby
39
39
  has_one_attached :video do |attachable|
@@ -41,14 +41,14 @@ has_one_attached :video do |attachable|
41
41
  attachable.variant :web,
42
42
  transformer: VideoTranscoder,
43
43
  codec: "vp9",
44
- fallback: :original
44
+ processing: :original
45
45
 
46
46
  # Async with default transformer (large image resize that's too slow for inline)
47
47
  attachable.variant :thumbnail,
48
48
  resize_to_limit: [200, 200],
49
- fallback: :original
49
+ processing: :original
50
50
 
51
- # Sync with custom transformer (fast custom processing, no fallback needed)
51
+ # Sync with custom transformer (fast custom processing, no opt-in needed)
52
52
  attachable.variant :watermarked,
53
53
  transformer: WatermarkStamper
54
54
  end
@@ -62,6 +62,45 @@ In views, use the same Active Storage helpers:
62
62
 
63
63
  If the variant is still processing, this serves the original video. Once processing completes, it serves the transcoded variant.
64
64
 
65
+ ## `image_tag` / `video_tag` with `async:` and `direct:`
66
+
67
+ For a polish layer that swaps in placeholder content while a variant is being processed, polls for completion in the background, and optionally serves the finished variant straight from your CDN, the gem adds two options to `image_tag` and `video_tag`:
68
+
69
+ ```erb
70
+ <%# Variant URL with the async client wired up: spinner while pending,
71
+ polls for completion, swaps to the real image when ready. %>
72
+ <%= image_tag user.avatar.variant(:web), async: true %>
73
+
74
+ <%# When the variant is ready, render its direct CDN/S3 URL instead of
75
+ routing through Rails. While pending/failed, falls back to the
76
+ Rails representation URL (which serves the placeholder). %>
77
+ <%= image_tag user.avatar.variant(:web), direct: true %>
78
+
79
+ <%# Both together: direct URL once processed, placeholder while pending,
80
+ polling for completion. %>
81
+ <%= image_tag user.avatar.variant(:web), async: true, direct: true %>
82
+
83
+ <%# Same for videos. %>
84
+ <%= video_tag user.video.variant(:web), async: true, controls: true %>
85
+ ```
86
+
87
+ The first argument must be a `VariantWithRecord` or `Preview` when either option is set; otherwise `image_tag` / `video_tag` behave exactly as in stock Rails.
88
+
89
+ ### Configure the direct URL host
90
+
91
+ By default `direct:` uses the storage service's URL (presigned for private buckets, unsigned for public). To serve from a CDN, set the host in an initializer:
92
+
93
+ ```ruby
94
+ # config/initializers/active_storage_async_variants.rb
95
+ ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"
96
+ ```
97
+
98
+ The resulting URL is `"#{cdn_host}/#{variant.key}"`.
99
+
100
+ ### JavaScript
101
+
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.
103
+
65
104
  ## Writing a Transformer
66
105
 
67
106
  Transformers come in two flavors: **inline** (the job blocks until processing completes) and **external** (the job kicks off remote work and a webhook signals completion).
@@ -158,41 +197,42 @@ The gem determines the mode by which method the transformer implements: `initiat
158
197
  ```ruby
159
198
  variant = user.video.variant(:web)
160
199
 
161
- variant.ready? # => true if processed successfully
200
+ variant.processed? # => true if processed successfully
162
201
  variant.processing? # => true if job is running or external service is working
163
202
  variant.pending? # => true if job is enqueued
164
203
  variant.failed? # => true if permanently failed
165
204
  variant.error # => error message string, or nil
166
205
  ```
167
206
 
168
- ## Fallback Options
207
+ ## Placeholder Options
169
208
 
170
- The `fallback:` option controls what gets served while a variant is processing (or after it fails):
209
+ The `processing:` option controls what gets served while a variant is being processed. The `failed:` option (optional) controls what gets served once the variant has permanently failed; if omitted, it defaults to the `processing:` value.
171
210
 
172
211
  ```ruby
173
- # Serve the original unprocessed file
174
- attachable.variant :web, fallback: :original
212
+ # Serve the original unprocessed file while processing
213
+ attachable.variant :web, processing: :original
175
214
 
176
215
  # Return nil -- let the view handle it
177
- attachable.variant :web, fallback: :blank
216
+ attachable.variant :web, processing: :blank
217
+
218
+ # Static URL while processing
219
+ attachable.variant :web, processing: "/placeholders/processing.svg"
220
+
221
+ # Dynamic placeholder via Proc
222
+ attachable.variant :web,
223
+ processing: -> (blob) { "/placeholders/processing.svg" }
178
224
 
179
- # Custom fallback
225
+ # Distinct placeholder when permanently failed
180
226
  attachable.variant :web,
181
- fallback: -> (blob) { "/placeholders/processing.svg" }
227
+ processing: :original,
228
+ failed: "/icons/broken.svg"
182
229
  ```
183
230
 
184
- ## Failure Handling
231
+ Both options accept the same set of values: `:original`, `:blank`, a String URL, or a Proc that receives the blob.
185
232
 
186
- By default, a variant is retried 3 times before being marked as permanently failed. Configure per-variant:
233
+ ## Failure Handling
187
234
 
188
- ```ruby
189
- attachable.variant :web,
190
- transformer: VideoTranscoder,
191
- codec: "vp9",
192
- resolution: "720p",
193
- fallback: :original,
194
- max_retries: 5
195
- ```
235
+ A variant is retried up to 3 times before being marked as permanently failed. Once permanently failed, `.processed` no longer re-enqueues the job — the variant stays failed until you delete the `ActiveStorage::VariantRecord` row manually (or re-attach a fresh blob).
196
236
 
197
237
  Inspect failures:
198
238
 
@@ -213,15 +253,15 @@ variant.error # => "ffmpeg exited with status 1: ..."
213
253
  5. The external service processes the file, uploads the result to the destination URL
214
254
  6. The external service POSTs to the callback URL with success/failure status
215
255
  7. The gem's callback endpoint transitions the `VariantRecord` to `processed` or `failed`
216
- 8. When a view requests the variant URL, the gem checks state and serves the variant or the fallback
256
+ 8. When a view requests the variant URL, the gem checks state and serves the variant or the `processing:`/`failed:` placeholder
217
257
 
218
258
  ### Inline transformer flow
219
259
 
220
260
  1-3. Same as above
221
261
  4. The job calls the transformer's `process` method, blocking until complete
222
262
  5. On success, the output is uploaded, the `VariantRecord` transitions to `processed`
223
- 6. On failure, the error is recorded and the job is re-enqueued (up to `max_retries`)
224
- 7. When a view requests the variant URL, the gem checks state and serves the variant or the fallback
263
+ 6. On failure, the error is recorded and the job is re-enqueued (up to 3 attempts)
264
+ 7. When a view requests the variant URL, the gem checks state and serves the variant or the `processing:`/`failed:` placeholder
225
265
 
226
266
  ## License
227
267
 
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
@@ -10,8 +10,10 @@ module ActiveStorage
10
10
  case params[:status]
11
11
  when "success"
12
12
  variant_record.update!(state: "processed")
13
+ apply_reported_metadata(variant_record, params)
13
14
  when "failed"
14
- variant_record.update!(state: "failed", error: params[:error])
15
+ # error column is TEXT (64KB); utf8mb4 is up to 4 bytes/char, so cap at 16k chars.
16
+ variant_record.update!(state: "failed", error: params[:error].to_s.truncate(16_000))
15
17
  else
16
18
  head :unprocessable_entity and return
17
19
  end
@@ -20,6 +22,37 @@ module ActiveStorage
20
22
  rescue ActiveSupport::MessageVerifier::InvalidSignature
21
23
  head :unauthorized
22
24
  end
25
+
26
+ private
27
+
28
+ # External transformers (Crucible) write the file directly to the bucket
29
+ # and report its byte_size/checksum on the success callback. Reconcile the
30
+ # placeholder blobs created with byte_size: 0, checksum: "0" -- the variant
31
+ # itself, and (for video previews) the extracted frame on the source blob.
32
+ def apply_reported_metadata(variant_record, params)
33
+ reconcile(variant_record.image.blob, params[:byte_size], params[:checksum])
34
+ reconcile(variant_record.blob, params[:preview_image_byte_size], params[:preview_image_checksum])
35
+ end
36
+
37
+ # The placeholder sentinels (byte_size 0, checksum "0") gate each field, so
38
+ # this is idempotent and never overwrites a real source blob's metadata.
39
+ def reconcile(blob, byte_size, checksum)
40
+ return unless blob
41
+
42
+ attrs = {}
43
+ if (bytes = positive_int(byte_size)) && blob.byte_size.zero?
44
+ attrs[:byte_size] = bytes
45
+ end
46
+ if checksum.present? && checksum != "0" && blob.checksum == "0"
47
+ attrs[:checksum] = checksum
48
+ end
49
+ blob.update!(attrs) if attrs.any?
50
+ end
51
+
52
+ def positive_int(value)
53
+ int = value.to_i
54
+ int.positive? ? int : nil
55
+ end
23
56
  end
24
57
  end
25
58
  end
@@ -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! }
@@ -6,6 +6,7 @@ gem "appraisal"
6
6
  gem "irb"
7
7
  gem "rake", "~> 13.0"
8
8
  gem "rspec", "~> 3.0"
9
+ gem "simplecov", require: false
9
10
  gem "rspec-rails"
10
11
  gem "rails", "~> 7.2.0"
11
12
  gem "sqlite3"
@@ -6,6 +6,7 @@ gem "appraisal"
6
6
  gem "irb"
7
7
  gem "rake", "~> 13.0"
8
8
  gem "rspec", "~> 3.0"
9
+ gem "simplecov", require: false
9
10
  gem "rspec-rails"
10
11
  gem "rails", "~> 8.0.0"
11
12
  gem "sqlite3"
@@ -6,6 +6,7 @@ gem "appraisal"
6
6
  gem "irb"
7
7
  gem "rake", "~> 13.0"
8
8
  gem "rspec", "~> 3.0"
9
+ gem "simplecov", require: false
9
10
  gem "rspec-rails"
10
11
  gem "rails", "~> 8.1.0"
11
12
  gem "sqlite3"
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
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.
8
+ module AssetTagHelperExtension
9
+ include ActiveStorage::AsyncVariants::Helper
10
+
11
+ def image_tag(source, options = {})
12
+ async_variant_tag(:image, [source], options) do |urls, opts|
13
+ super(urls.first, opts)
14
+ end
15
+ end
16
+
17
+ def video_tag(*sources)
18
+ options = sources.extract_options!
19
+ async_variant_tag(:video, sources, options) do |urls, opts|
20
+ super(*urls, opts)
21
+ end
22
+ end
23
+
24
+ private
25
+
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
31
+
32
+ variant, *rest = sources
33
+ assert_async_variant!(variant)
34
+
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)
39
+ end
40
+ else
41
+ yield [async_variant_resolved_src(variant, direct:), *rest], options
42
+ end
43
+ end
44
+
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}"
48
+ end
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
60
+ end
61
+ end
62
+ end