inertia_rails 3.21.2 → 3.22.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: 190a5d8589ff4eed036766ab20f769f4bb330e09e3097a6682a30c9a8121ceef
4
- data.tar.gz: 54f4aac04e72db5de364d525851a68de80590aed5ae1220055871d10cbb3ef05
3
+ metadata.gz: 4e9384ee4e756ec30efcbb0afa77c7d557d02dd4d5dc60f1dc6239ec1d0b1878
4
+ data.tar.gz: 98e207f798ae91e888f1de274caa506bbc289ac51ea2e20e2eb5465f818575fd
5
5
  SHA512:
6
- metadata.gz: 9c1f35a30b2357eb934fdf33d4f6d51067d0771de5d1081093be4e9858bc9022bde74b799b40d18a3b59e09ca9d8811b4c8fbb91d0062b4a9280b40ab9bca7c5
7
- data.tar.gz: a47aec16b0e2502305cfa41c48941586036bd322cc74c84f3f6cfb783aeb7b97835bc810f6e35ef1b70c40554a945e05979123cf710819eec50aa6ea2978c8ea
6
+ metadata.gz: 6d9dd387eb80c39743d7d48268ef7540710a27194c52f165e4f75565fd4e76cc151db286b64b5e050fa387027b80a0eb3b37210908068a826cfedfe6003aea67
7
+ data.tar.gz: 3309e07158894db2e60b03c15e0f46b8d056e09821487412709b738a5ceeb2508c54d5df7421ad77ee04e30a2592e8a07a48de720192446ad5290e18c423eb47
data/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.22.0] - 2026-07-17
10
+
11
+ * Only set the `XSRF-TOKEN` cookie on HTML and XHR responses. Other responses no longer carry a `Set-Cookie` that keeps CDNs from caching them — notably ActiveStorage's images, since its controllers also inherit from `ActionController::Base` (@acetinick)
12
+ * Add `xsrf_cookie_refresh` configuration option. Set to `:lazy` to skip rewriting the `XSRF-TOKEN` cookie on safe requests when a valid cookie is already present, keeping conditionally cached (`ETag`/`304`) responses free of `Set-Cookie` churn (@darkamenosa)
13
+ * Fix `inertia: { errors: }` and `inertia: { clear_history: }` being discarded when `redirect_to` is given an explicit non-302 status such as `status: :see_other` (303/307/308) (@skryukov)
14
+ * Fix cross-variant `304 Not Modified` responses under HTTP conditional caching (`fresh_when` / `stale?`) by folding the Inertia request headers into the ETag, so HTML, JSON, and partial-reload representations of the same URL no longer share a validator (@skryukov)
15
+ * Generate controller views at the paths resolved by `render inertia: true` (@oiahoon)
16
+ * Add `@inertiajs/core` as a direct dev dependency in the TypeScript install generator, so pnpm exposes the adapter types that `globals.d.ts` augments (@heyrobertchang)
17
+ * Fix option handling in the `inertia` routes helper: user-supplied `defaults:` no longer replace the component (previously producing `component: null` responses), the route-to-component pair no longer leaks into `params`, and additional String-keyed pairs define routes instead of being silently dropped (@skryukov)
18
+ * Raise a clear error when `parent_controller` does not inherit from `ActionController::Base` instead of an opaque `NoMethodError` (@skryukov)
19
+ * Add static `props:` support to the `inertia` routes helper: `inertia 'about' => 'About', props: { title: 'About us' }` (@skryukov)
20
+ * Deduplicate `X-Inertia` in the `Vary` response header (@skryukov)
21
+
9
22
  ## [3.21.2] - 2026-06-09
10
23
 
11
24
  * Add `rescue: true` option to `InertiaRails.defer` to rescue and report exceptions raised while resolving a deferred prop (@skryukov)
@@ -3,8 +3,16 @@
3
3
  module InertiaRails
4
4
  class StaticController < InertiaRails.configuration.parent_controller.constantize
5
5
  def static
6
+ # Checked at dispatch, not load: eager loading defines this class even
7
+ # in apps that never draw an `inertia` route, and those must still boot.
8
+ unless is_a?(::ActionController::Base)
9
+ raise ArgumentError,
10
+ '`config.parent_controller` must inherit from ActionController::Base to serve `inertia` routes, ' \
11
+ "got #{InertiaRails.configuration.parent_controller.inspect}"
12
+ end
13
+
6
14
  respond_to do |format|
7
- format.html { render inertia: params[:component] }
15
+ format.html { render inertia: params[:component], props: request.path_parameters[:props]&.deep_dup || {} }
8
16
  end
9
17
  end
10
18
  end
@@ -31,7 +31,8 @@ vue:
31
31
  - "@vitejs/plugin-vue"
32
32
  - "vite@latest"
33
33
  packages_ts:
34
- - "typescript"
34
+ # vue-tsc requires typescript/lib/tsc, removed in TypeScript 7
35
+ - "typescript@^6"
35
36
  - "vue-tsc"
36
37
  vite_plugin_import: "import vue from '@vitejs/plugin-vue'"
37
38
  vite_plugin_call: "vue()"
@@ -55,7 +56,8 @@ svelte:
55
56
  packages_ts:
56
57
  - "@tsconfig/svelte@5"
57
58
  - "svelte-check"
58
- - "typescript"
59
+ # svelte-check uses the TypeScript API, gone from TypeScript 7's package entry
60
+ - "typescript@^6"
59
61
  - "tslib"
60
62
  vite_plugin_import: "import { svelte } from '@sveltejs/vite-plugin-svelte'"
61
63
  vite_plugin_call: "svelte()"
@@ -142,7 +142,13 @@ module Inertia
142
142
  def install_typescript
143
143
  say 'Adding TypeScript support'
144
144
 
145
- add_dependencies(*FRAMEWORKS[framework]['packages_ts'], dev: true)
145
+ # globals.d.ts augments @inertiajs/core and scaffolds import its types;
146
+ # pnpm won't expose the adapters' transitive dep, so declare it directly.
147
+ add_dependencies(
148
+ "@inertiajs/core@#{options[:inertia_version]}",
149
+ *FRAMEWORKS[framework]['packages_ts'],
150
+ dev: true
151
+ )
146
152
 
147
153
  say 'Copying tsconfig and types'
148
154
 
@@ -3,6 +3,7 @@
3
3
  module InertiaRails
4
4
  class Configuration
5
5
  DEFAULT_SSR_URL = 'http://localhost:13714'
6
+ XSRF_COOKIE_REFRESH_OPTIONS = %i[always lazy].freeze
6
7
 
7
8
  DEFAULTS = {
8
9
  # Whether to combine hashes with the same keys instead of replacing them.
@@ -51,6 +52,9 @@ module InertiaRails
51
52
  # Whether to include empty `errors` hash to the props when no errors are present.
52
53
  always_include_errors_hash: nil,
53
54
 
55
+ # When to refresh the XSRF token cookie on protected requests.
56
+ xsrf_cookie_refresh: :always,
57
+
54
58
  # Whether to use `<script>` element for initial page rendering instead of the `data-page` attribute.
55
59
  use_script_element_for_initial_page: false,
56
60
 
@@ -149,6 +153,17 @@ module InertiaRails
149
153
  @options[:cache_store] || Rails.cache
150
154
  end
151
155
 
156
+ # Normalized and validated at read time — ENV values arrive as strings, and callables are only evaluated here.
157
+ def xsrf_cookie_refresh
158
+ value = evaluate_option(options[:xsrf_cookie_refresh])
159
+ value = value.to_sym if value.respond_to?(:to_sym)
160
+ return value if XSRF_COOKIE_REFRESH_OPTIONS.include?(value)
161
+
162
+ raise ArgumentError,
163
+ "Invalid xsrf_cookie_refresh: #{value.inspect}. " \
164
+ "Expected one of: #{XSRF_COOKIE_REFRESH_OPTIONS.map(&:inspect).join(', ')}"
165
+ end
166
+
152
167
  OPTION_NAMES.each do |option|
153
168
  unless method_defined?(option)
154
169
  define_method(option) do
@@ -11,8 +11,18 @@ module InertiaRails
11
11
  InertiaRails::Current.request = request
12
12
  end
13
13
 
14
+ # The same URL answers as HTML, Inertia JSON, or a partial reload; fold the
15
+ # representation into the conditional-GET validator so they don't share an ETag.
16
+ etag { inertia_conditional_get_variant }
17
+
14
18
  after_action do
15
- cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
19
+ next unless protect_against_forgery?
20
+ # Included into ActionController::Base, so this runs for non-Inertia
21
+ # responses too — ActiveStorage images, where it only breaks CDN caching.
22
+ next unless request.format.html? || request.xhr?
23
+ next if XsrfCookieRefreshPolicy.skip?(self)
24
+
25
+ cookies['XSRF-TOKEN'] = form_authenticity_token
16
26
  end
17
27
 
18
28
  rescue_from InertiaRails::PrecognitionResponse do |e|
@@ -112,6 +122,13 @@ module InertiaRails
112
122
  view_assigns.except(*@_inertia_skip_props)
113
123
  end
114
124
 
125
+ # nil for plain requests — compacted out of the ETag, so non-Inertia ETags are unchanged.
126
+ def inertia_conditional_get_variant
127
+ return unless request.inertia?
128
+
129
+ request.env.filter_map { |key, value| "#{key}=#{value}" if key.start_with?('HTTP_X_INERTIA') }.sort
130
+ end
131
+
115
132
  # Rails < 8: _normalize_options overwrites :layout with a resolved default,
116
133
  # making an explicit `layout: false` indistinguishable from "not provided".
117
134
  # Stash the original value so the renderer can tell the two apart.
@@ -3,17 +3,29 @@
3
3
  module InertiaRails
4
4
  module InertiaMapper
5
5
  def inertia(*args, **options)
6
- path = args.any? ? args.first : options
7
- route, component = extract_route_and_component(path)
8
- get(route, to: StaticController.action(:static), defaults: { component: component }, **options)
6
+ defaults = options.delete(:defaults) || {}
7
+ defaults = defaults.merge(props: options.delete(:props)) if options.key?(:props)
8
+
9
+ extract_routes(args, options).each do |route, component|
10
+ get(route, to: StaticController.action(:static), defaults: defaults.merge(component: component), **options)
11
+ end
9
12
  end
10
13
 
11
14
  private
12
15
 
13
- def extract_route_and_component(path)
14
- if path.is_a?(Hash)
15
- path.first
16
- elsif resource_scope?
16
+ # The first hash pair is the route; any further String-keyed pairs are
17
+ # additional routes. Symbol-keyed leftovers are route options (`on:`, `as:`).
18
+ def extract_routes(args, options)
19
+ return [route_with_default_component(args.first)] if args.any?
20
+
21
+ route = options.keys.first
22
+ routes = [[route, options.delete(route)]]
23
+ options.keys.grep(String).each { |extra| routes << [extra, options.delete(extra)] }
24
+ routes
25
+ end
26
+
27
+ def route_with_default_component(path)
28
+ if resource_scope?
17
29
  [path,
18
30
  InertiaRails.configuration.component_path_resolver(
19
31
  path: [@scope[:module], @scope[:controller]].compact.join('/'), action: path
@@ -8,8 +8,8 @@ module InertiaRails
8
8
  class ControllerTemplateBase < Rails::Generators::NamedBase
9
9
  include Helper
10
10
 
11
- class_option :frontend_framework, required: true, desc: 'Frontend framework to generate the views for.',
12
- default: Helper.guess_the_default_framework
11
+ class_option :frontend_framework,
12
+ desc: 'Frontend framework to generate the views for (defaults to the one detected in package.json).'
13
13
 
14
14
  class_option :typescript, type: :boolean, desc: 'Whether to use TypeScript',
15
15
  default: Helper.uses_typescript?
@@ -23,13 +23,17 @@ module InertiaRails
23
23
  def copy_view_files
24
24
  actions.each do |action|
25
25
  @action = action
26
- @path = File.join(base_path, "#{action.camelize}.#{extension}")
27
- template "#{options.frontend_framework}/#{template_filename}.#{extension}", @path
26
+ @path = File.join(base_path, "#{action}.#{extension}")
27
+ template "#{frontend_framework}/#{template_filename}.#{extension}", @path
28
28
  end
29
29
  end
30
30
 
31
31
  private
32
32
 
33
+ def frontend_framework
34
+ options[:frontend_framework] || Helper.guess_the_default_framework
35
+ end
36
+
33
37
  def base_path
34
38
  File.join(pages_path, inertia_base_path)
35
39
  end
@@ -47,12 +51,12 @@ module InertiaRails
47
51
  end
48
52
 
49
53
  def extension
50
- case options.frontend_framework
54
+ case frontend_framework
51
55
  when 'react' then typescript? ? 'tsx' : 'jsx'
52
56
  when 'vue' then 'vue'
53
57
  when 'svelte' then 'svelte'
54
58
  else
55
- raise ArgumentError, "Unknown frontend framework: #{options.frontend_framework}"
59
+ raise ArgumentError, "Unknown frontend framework: #{frontend_framework}"
56
60
  end
57
61
  end
58
62
 
@@ -27,6 +27,8 @@ module InertiaRails
27
27
  end
28
28
 
29
29
  def guess_inertia_template(package_json_path = DEFAULT_PACKAGE_PATH)
30
+ return 'inertia_templates' unless package_json_path.exist?
31
+
30
32
  if package_json_path.read.include?('"tailwindcss"')
31
33
  'inertia_tw_templates'
32
34
  else
@@ -35,8 +37,9 @@ module InertiaRails
35
37
  end
36
38
  end
37
39
 
40
+ # Matches controller_path, which the default component path resolver expects.
38
41
  def inertia_base_path
39
- (class_path + [file_name.pluralize]).join('/')
42
+ (class_path + [file_name]).join('/')
40
43
  end
41
44
 
42
45
  def inertia_component_name
@@ -14,21 +14,26 @@ module InertiaRails
14
14
 
15
15
  def copy_view_files
16
16
  available_views.each do |view|
17
- template "#{options.frontend_framework}/#{view}.#{template_extension}",
17
+ template "#{frontend_framework}/#{view}.#{template_extension}",
18
18
  File.join(base_path, "#{view}.#{extension}")
19
19
  end
20
20
 
21
- template "#{options.frontend_framework}/#{partial_name}.#{template_extension}",
21
+ template "#{frontend_framework}/#{partial_name}.#{template_extension}",
22
22
  File.join(base_path, "#{singular_name}.#{extension}")
23
23
 
24
- template "#{options.frontend_framework}/types.ts", File.join(base_path, 'types.ts') if typescript?
24
+ template "#{frontend_framework}/types.ts", File.join(base_path, 'types.ts') if typescript?
25
25
  end
26
26
 
27
27
  private
28
28
 
29
+ # Scaffold controllers are pluralized, so the views live under the pluralized path.
30
+ def inertia_base_path
31
+ (controller_class_path + [controller_file_name]).join('/')
32
+ end
33
+
29
34
  def template_extension
30
35
  return extension unless typescript?
31
- return 'tsx' if options.frontend_framework == 'react'
36
+ return 'tsx' if frontend_framework == 'react'
32
37
 
33
38
  "ts.#{extension}"
34
39
  end
@@ -51,7 +51,15 @@ module InertiaRails
51
51
  inertia_request? && version_stale?
52
52
  end
53
53
 
54
+ # Matches Rack::Response::Helpers#redirect? — Inertia session options
55
+ # must survive every redirect until a render consumes them.
54
56
  def redirect_status?(status)
57
+ [301, 302, 303, 307, 308].include? status
58
+ end
59
+
60
+ # Only 301/302 are rewritten to 303: a 303 already forces a GET on
61
+ # follow, and 307/308 preserve the request method by design.
62
+ def convertible_redirect_status?(status)
55
63
  [301, 302].include? status
56
64
  end
57
65
 
@@ -60,7 +68,7 @@ module InertiaRails
60
68
  end
61
69
 
62
70
  def inertia_non_post_redirect?(status)
63
- inertia_request? && redirect_status?(status) && non_get_redirectable_method?
71
+ inertia_request? && convertible_redirect_status?(status) && non_get_redirectable_method?
64
72
  end
65
73
 
66
74
  def stale_inertia_get?
@@ -44,11 +44,9 @@ module InertiaRails
44
44
  end
45
45
 
46
46
  def render
47
- @response.headers['Vary'] = if @response.headers['Vary'].blank?
48
- 'X-Inertia'
49
- else
50
- "#{@response.headers['Vary']}, X-Inertia"
51
- end
47
+ vary = @response.headers['Vary'].to_s.split(',').map(&:strip).reject(&:empty?)
48
+ vary << 'X-Inertia' if vary.none? { |value| value.casecmp?('X-Inertia') }
49
+ @response.headers['Vary'] = vary.join(', ')
52
50
  if @request.inertia?
53
51
  @response.set_header('X-Inertia', 'true')
54
52
  @render_method.call json: page.to_json, status: @response.status, content_type: Mime[:json]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InertiaRails
4
- VERSION = '3.21.2'
4
+ VERSION = '3.22.0'
5
5
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaRails
4
+ # Decides whether the XSRF-TOKEN cookie rewrite can be skipped under the `:lazy` refresh policy.
5
+ class XsrfCookieRefreshPolicy
6
+ def self.skip?(controller)
7
+ new(controller).skip?
8
+ end
9
+
10
+ def initialize(controller)
11
+ @controller = controller
12
+ @request = controller.request
13
+ end
14
+
15
+ def skip?
16
+ return false unless configuration.xsrf_cookie_refresh == :lazy
17
+ return false unless @request.get? || @request.head?
18
+
19
+ cookie = @request.cookies['XSRF-TOKEN']
20
+ return false if cookie.blank?
21
+
22
+ return true unless can_validate_without_loading_session?
23
+
24
+ valid_for_session?(cookie)
25
+ end
26
+
27
+ private
28
+
29
+ def configuration
30
+ @controller.send(:inertia_configuration)
31
+ end
32
+
33
+ def can_validate_without_loading_session?
34
+ csrf_token_loaded_in_env? ||
35
+ (@request.session.respond_to?(:loaded?) && @request.session.loaded?)
36
+ end
37
+
38
+ def valid_for_session?(cookie)
39
+ csrf_token_was_loaded = csrf_token_loaded_in_env?
40
+ @controller.send(:valid_authenticity_token?, @request.session, cookie)
41
+ ensure
42
+ # `valid_authenticity_token?` memoizes the real token into
43
+ # `request.env[CSRF_TOKEN]` (Rails 7.1+), and the session middleware later
44
+ # persists whatever sits there via `commit_csrf_token`. Drop the key when
45
+ # validation itself created it, so a validation-only read can't dirty the
46
+ # session and emit a session Set-Cookie. No-op on Rails < 7.1, where
47
+ # there is no env key to clean up.
48
+ @request.env.delete(csrf_token_env_key) if csrf_token_env_key && !csrf_token_was_loaded
49
+ end
50
+
51
+ def csrf_token_loaded_in_env?
52
+ csrf_token_env_key && @request.env.key?(csrf_token_env_key)
53
+ end
54
+
55
+ def csrf_token_env_key
56
+ return @csrf_token_env_key if defined?(@csrf_token_env_key)
57
+
58
+ @csrf_token_env_key =
59
+ if ActionController::RequestForgeryProtection.const_defined?(:CSRF_TOKEN, false)
60
+ ActionController::RequestForgeryProtection.const_get(:CSRF_TOKEN)
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/inertia_rails.rb CHANGED
@@ -43,6 +43,7 @@ require_relative 'inertia_rails/renderer'
43
43
  require_relative 'inertia_rails/flash_extension'
44
44
  require_relative 'inertia_rails/helper'
45
45
  require_relative 'inertia_rails/precognition'
46
+ require_relative 'inertia_rails/xsrf_cookie_refresh_policy'
46
47
  require_relative 'inertia_rails/controller'
47
48
  require_relative 'inertia_rails/middleware'
48
49
  require_relative 'inertia_rails/engine'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inertia_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.21.2
4
+ version: 3.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Knoles
@@ -225,6 +225,7 @@ files:
225
225
  - lib/inertia_rails/ssr_renderer.rb
226
226
  - lib/inertia_rails/testing.rb
227
227
  - lib/inertia_rails/version.rb
228
+ - lib/inertia_rails/xsrf_cookie_refresh_policy.rb
228
229
  - lib/puma/plugin/inertia_ssr.rb
229
230
  - lib/tasks/inertia_rails.rake
230
231
  homepage: https://github.com/inertiajs/inertia-rails