inertia_rails 3.21.1 → 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: e75f704d4ac9af2da022ca9e0fb08f2954820e15095b8845809af1fc2b3470fa
4
- data.tar.gz: 5ac24d6b30235de6c66f5bab59eb526e72804df26cb265c3894d214fbfd7fa98
3
+ metadata.gz: 4e9384ee4e756ec30efcbb0afa77c7d557d02dd4d5dc60f1dc6239ec1d0b1878
4
+ data.tar.gz: 98e207f798ae91e888f1de274caa506bbc289ac51ea2e20e2eb5465f818575fd
5
5
  SHA512:
6
- metadata.gz: ed6bd7549422be3fffff41dfaf41502ea8d6850f23a408db31616b29ef7d1fecf3c64e74a46d6a425d80bc2c70161402b92ad25b560691ec9ef36f31403d1dba
7
- data.tar.gz: 1154a428ebd8ffd146317e7aa98b996337ae445fa5f4197dac07491837b2f00d8ba13562e621b701725bebf40243a05eaf74775bf67fdef85d3ab0b008b1c1fb
6
+ metadata.gz: 6d9dd387eb80c39743d7d48268ef7540710a27194c52f165e4f75565fd4e76cc151db286b64b5e050fa387027b80a0eb3b37210908068a826cfedfe6003aea67
7
+ data.tar.gz: 3309e07158894db2e60b03c15e0f46b8d056e09821487412709b738a5ceeb2508c54d5df7421ad77ee04e30a2592e8a07a48de720192446ad5290e18c423eb47
data/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ 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
+
22
+ ## [3.21.2] - 2026-06-09
23
+
24
+ * Add `rescue: true` option to `InertiaRails.defer` to rescue and report exceptions raised while resolving a deferred prop (@skryukov)
25
+ * Add CSP nonce to the initial page script when needed (@nicholaspufal)
26
+ * Fix Vite install to use the configured package manager in the generator (@akicho8)
27
+ * Fix TypeScript packages installed as dependencies instead of devDependencies (@alec-c4)
28
+
9
29
  ## [3.21.1] - 2026-05-19
10
30
 
11
31
  * Specify initializer run order for middleware insertion to avoid frozen middleware stack errors on Rails 8.1+ (@julik)
data/README.md CHANGED
@@ -1,347 +1,155 @@
1
- ![image](https://user-images.githubusercontent.com/6599653/114456558-032e2200-9bab-11eb-88bc-a19897f417ba.png)
2
-
3
-
4
- # Inertia.js Rails Adapter
5
-
6
- ## Installation
7
-
8
- ### Backend
9
-
10
- Add the `inertia_rails` gem to your Gemfile.
11
-
12
- ```ruby
13
- gem 'inertia_rails'
14
- ```
15
-
16
- Follow the complete [Server-side setup](https://inertia-rails.dev/guide/server-side-setup) in the official documentation.
17
-
18
- ### Frontend
19
-
20
- Follow the [Client-side setup](https://inertia-rails.dev/guide/client-side-setup) guide for detailed configuration steps.
21
-
22
- ### Example Projects:
23
-
24
- Reference these sample implementations:
25
-
26
- - [React/Vite](https://github.com/BrandonShar/inertia-rails-template)
27
- - [React/Vite + SSR](https://github.com/ElMassimo/inertia-rails-ssr-template)
28
- - [PingCRM with Vue and Vite](https://github.com/ledermann/pingcrm)
29
-
30
- ## Usage
31
-
32
- ### Responses
33
-
34
- Render Inertia responses is simple, just use the inertia renderer in your controller methods. The renderer accepts two arguments, the first is the name of the component you want to render from within your pages directory (without extension). The second argument is an options hash where you can provide `props` to your components. This options hash also allows you to pass `view_data` to your layout, but this is much less common.
35
-
36
- ```ruby
37
- def index
38
- render inertia: 'Event/Index', props: {
39
- events: Event.all,
40
- }
41
- end
42
- ```
43
-
44
- #### Rails Component and Instance Props
45
-
46
- Starting in version 3.0, Inertia Rails allows you to provide your component name and props via common rails conventions.
47
-
48
- ```ruby
49
- class EventsController < ApplicationController
50
- use_inertia_instance_props
51
-
1
+ <div align="center">
2
+ <a href="https://inertia-rails.dev">
3
+ <img src="https://inertia-rails.dev/logo.svg" alt="Inertia Rails" width="150">
4
+ </a>
5
+
6
+ <h1>Build frontend experiences with the backend you love</h1>
7
+
8
+ <p>
9
+ <strong>Single-page React, Vue, and Svelte apps powered by your existing Rails
10
+ controllers, routes, and authentication. No API required.</strong>
11
+ </p>
12
+
13
+ <p>
14
+ <a href="https://rubygems.org/gems/inertia_rails"><img src="https://img.shields.io/gem/v/inertia_rails" alt="Gem version"></a>
15
+ <a href="https://rubygems.org/gems/inertia_rails"><img src="https://img.shields.io/gem/dt/inertia_rails" alt="Downloads"></a>
16
+ <a href="https://github.com/inertiajs/inertia-rails/actions/workflows/push.yml"><img src="https://github.com/inertiajs/inertia-rails/actions/workflows/push.yml/badge.svg" alt="Build status"></a>
17
+ <a href="https://github.com/inertiajs/inertia-rails/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
18
+ <a href="https://discord.gg/inertiajs"><img src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
19
+ </p>
20
+
21
+ <p>
22
+ <a href="https://inertia-rails.dev"><strong>Documentation</strong></a> ·
23
+ <a href="https://inertia-rails.dev/guide/server-side-setup"><strong>Get started</strong></a> ·
24
+ <a href="https://inertia-rails.dev/guide/demo-application"><strong>Demo</strong></a> ·
25
+ <a href="https://discord.gg/inertiajs"><strong>Discord</strong></a>
26
+ </p>
27
+ </div>
28
+
29
+ ---
30
+
31
+ ## Your controllers. Your routes. Modern components.
32
+
33
+ Inertia lets you build a fully client-side rendered single-page app without the
34
+ complexity of a separate API. Pass data from Rails directly to React, Vue, or
35
+ Svelte as **props** — no REST endpoints, no GraphQL, no client-side data
36
+ fetching, no state-management headaches.
37
+
38
+ ```ruby
39
+ # app/controllers/users_controller.rb
40
+ class UsersController < ApplicationController
52
41
  def index
53
- @events = Event.all
54
- end
55
-
56
- end
57
- ```
58
-
59
- is the same as
60
-
61
-
62
- ```ruby
63
- class EventsController < ApplicationController
64
- def index
65
- render inertia: 'events/index', props: {
66
- events: Event.all
42
+ render inertia: {
43
+ users: User.active.map { |user| user.as_json(only: [:id, :name, :email]) }
67
44
  }
68
45
  end
69
46
  end
70
47
  ```
71
48
 
72
- #### Instance Props and Default Render Notes
73
-
74
- In order to use instance props, you must call `use_inertia_instance_props` on the controller (or a base controller it inherits from). If any props are provided manually, instance props
75
- are automatically disabled for that response. Instance props are only included if they are defined after the before filter is set from `use_inertia_instance_props`.
49
+ ```jsx
50
+ // app/frontend/pages/users/index.jsx
51
+ import { Link } from '@inertiajs/react'
76
52
 
77
- Automatic component name is also opt in, you must set the [`default_render`](#default_render) config value to `true`. Otherwise, you can simply `render inertia: true` for the same behavior explicitly.
78
-
79
- If the default component path doesn't match your convention, you can define a method to resolve it however you like via the `component_path_resolver` config value. The value of this should be callable and will receive the path and action and should return a string component path.
80
-
81
- ```ruby
82
- inertia_config(
83
- component_path_resolver: ->(path:, action:) do
84
- "Storefront/#{path.camelize}/#{action.camelize}"
85
- end
53
+ const Users = ({ users }) => (
54
+ <>
55
+ {users.map((user) => (
56
+ <div key={user.id}>
57
+ <Link href={`/users/${user.id}`}>{user.name}</Link>
58
+ <p>{user.email}</p>
59
+ </div>
60
+ ))}
61
+ </>
86
62
  )
87
63
 
64
+ export default Users
88
65
  ```
89
66
 
67
+ That's the whole loop: the controller returns props, the component renders them.
68
+ Links and form submits are intercepted and turned into XHR visits, so navigation
69
+ feels instant — but you're still writing plain Rails on the server.
90
70
 
71
+ ## Get started
91
72
 
92
- ### Layout
93
-
94
- Inertia layouts use the rails layout convention and can be set or changed in the same way.
95
-
96
- ```ruby
97
- class EventsController < ApplicationController
98
- layout 'inertia_application'
99
- end
100
- ```
101
-
102
-
103
- ### Shared Data
104
-
105
- If you have data that you want to be provided as a prop to every component (a common use-case is information about the authenticated user) you can use the `inertia_share` controller method.
106
-
107
- ```ruby
108
- class EventsController < ApplicationController
109
- # share synchronously
110
- inertia_share app_name: env['app.name']
111
-
112
- # share lazily, evaluated at render time
113
- inertia_share do
114
- if logged_in?
115
- {
116
- user: logged_in_user,
117
- }
118
- end
119
- end
120
-
121
- # share lazily alternate syntax
122
- inertia_share user_count: lambda { User.count }
123
-
124
- end
125
- ```
126
-
127
- #### Deep Merging Shared Data
128
-
129
- By default, Inertia will shallow merge data defined in an action with the shared data. You might want a deep merge. Imagine using shared data to represent defaults you'll override sometimes.
130
-
131
- ```ruby
132
- class ApplicationController
133
- inertia_share do
134
- { basketball_data: { points: 50, rebounds: 100 } }
135
- end
136
- end
137
- ```
138
-
139
- Let's say we want a particular action to change only part of that data structure. The renderer accepts a `deep_merge` option:
140
-
141
- ```ruby
142
- class CrazyScorersController < ApplicationController
143
- def index
144
- render inertia: 'CrazyScorersComponent',
145
- props: { basketball_data: { points: 100 } },
146
- deep_merge: true
147
- end
148
- end
149
-
150
- # The renderer will send this to the frontend:
151
- {
152
- basketball_data: {
153
- points: 100,
154
- rebounds: 100,
155
- }
156
- }
157
- ```
158
-
159
- Deep merging can be configured using the [`deep_merge_shared_data`](#deep_merge_shared_data) configuration option.
160
-
161
- If deep merging is enabled, you can still opt-out within the action:
162
-
163
- ```ruby
164
- class CrazyScorersController < ApplicationController
165
- inertia_config(deep_merge_shared_data: true)
166
-
167
- inertia_share do
168
- {
169
- basketball_data: {
170
- points: 50,
171
- rebounds: 10,
172
- }
173
- }
174
- end
175
-
176
- def index
177
- render inertia: 'CrazyScorersComponent',
178
- props: { basketball_data: { points: 100 } },
179
- deep_merge: false
180
- end
181
- end
73
+ **Add to an existing Rails app** — the installer sets up Vite, your chosen framework, and example pages:
182
74
 
183
- # `deep_merge: false` overrides the default:
184
- {
185
- basketball_data: {
186
- points: 100,
187
- }
188
- }
75
+ ```bash
76
+ bundle add inertia_rails
77
+ bin/rails generate inertia:install
189
78
  ```
190
79
 
191
- ### Optional Props
192
-
193
- On the frontend, Inertia supports the concept of "partial reloads" where only the props requested are returned by the server. Sometimes, you may want to use this flow to avoid processing a particularly slow prop on the initial load. In this case, you can use Optional props. Optional props aren't evaluated unless they're specifically requested by name in a partial reload.
80
+ **Or start from a kit** with authentication, Vite, optional SSR, and Kamal
81
+ deployment already wired up:
194
82
 
195
- ```ruby
196
- inertia_share some_data: InertiaRails.optional { some_very_slow_method }
197
- ```
83
+ - [React Starter Kit](https://github.com/inertia-rails/react-starter-kit) — React 19 · TypeScript · shadcn/ui
84
+ - [Vue Starter Kit](https://github.com/inertia-rails/vue-starter-kit) Vue 3 · TypeScript · shadcn-vue
85
+ - [Svelte Starter Kit](https://github.com/inertia-rails/svelte-starter-kit) — Svelte 5 · TypeScript · shadcn-svelte
198
86
 
199
- ### Routing
87
+ Full walkthrough: **[Server-side setup](https://inertia-rails.dev/guide/server-side-setup)** and **[Client-side setup](https://inertia-rails.dev/guide/client-side-setup)**.
200
88
 
201
- If you don't need a controller to handle a static component, you can route directly to a component with the inertia route helper
89
+ ## Built for real Rails apps
202
90
 
203
- ```ruby
204
- inertia 'about' => 'AboutComponent'
205
- ```
91
+ | | |
92
+ |---|---|
93
+ | **[Forms that work](https://inertia-rails.dev/guide/forms)** | Validation errors flow from Rails to your components automatically — no manual wiring. |
94
+ | **[Server-side rendering](https://inertia-rails.dev/guide/server-side-rendering)** | Full SSR for SEO and fast first paint. Your React/Vue/Svelte, rendered on Rails. |
95
+ | **[Test like Rails](https://inertia-rails.dev/guide/testing)** | RSpec and Minitest matchers that feel native. Assert on props, components, and more. |
96
+ | **[Partial reloads](https://inertia-rails.dev/guide/partial-reloads)** | Refresh only the data you need. Keep interactions snappy without full page loads. |
97
+ | **[Shared data](https://inertia-rails.dev/guide/shared-data)** | Current user, flash, permissions — available on every page automatically. |
98
+ | **[Deferred props](https://inertia-rails.dev/guide/deferred-props)** | Load the page fast, fetch expensive data after, with built-in loading states. |
99
+ | **[Rails generators](https://inertia-rails.dev/guide/server-side-setup)** | Scaffold entire CRUD interfaces — controllers with matching components. |
100
+ | **[History encryption](https://inertia-rails.dev/guide/history-encryption)** | Keep sensitive data private, even in browser history. Toggle per page. |
206
101
 
207
- ### SSR _(experimental)_
102
+ ## Why Inertia?
208
103
 
209
- Enable SSR via the configuration options for [`ssr_enabled`](#ssr_enabled-experimental) and [`ssr_url`](#ssr_url-experimental).
104
+ Inertia sits between traditional server-rendered apps and full SPAs.
210
105
 
211
- When using SSR, don't forget to add `<%= inertia_ssr_head %>` to the `<head>` of your layout (i.e. `application.html.erb`).
106
+ **vs. Hotwire** Same monolith, different view layer. Both keep you in Rails;
107
+ Inertia gives you the full React/Vue/Svelte component model and the npm
108
+ ecosystem instead of HTML-over-the-wire. Choose Hotwire for minimal JS and
109
+ server-rendered HTML; choose Inertia for a modern component architecture.
212
110
 
213
- ## Configuration ⚙️
111
+ **vs. API + SPA** — Same frontend, no API hassle. Both give you React/Vue/Svelte,
112
+ but Inertia removes the API layer entirely: one router (Rails), Rails sessions
113
+ instead of a JWT/OAuth dance, and props from your controller instead of fetching
114
+ in `useEffect`. Choose an API for public/mobile clients; choose Inertia for
115
+ focused web products. (You can always add an API alongside Inertia later.)
214
116
 
215
- Inertia Rails can be configured globally or in a specific controller (and subclasses).
117
+ See the full [comparison and FAQ](https://inertia-rails.dev/#why-inertia)
216
118
 
217
- ### Global Configuration
119
+ ## Documentation
218
120
 
219
- If using global configuration, we recommend you place the code inside an initializer:
121
+ Everything lives at **[inertia-rails.dev](https://inertia-rails.dev)**:
220
122
 
221
- ```ruby
222
- # config/initializers/inertia.rb
123
+ - [How it works](https://inertia-rails.dev/guide/how-it-works)
124
+ - [Pages & layouts](https://inertia-rails.dev/guide/pages)
125
+ - [Forms & validation](https://inertia-rails.dev/guide/forms)
126
+ - [Shared data](https://inertia-rails.dev/guide/shared-data) · [Partial reloads](https://inertia-rails.dev/guide/partial-reloads) · [Deferred props](https://inertia-rails.dev/guide/deferred-props)
127
+ - [Server-side rendering](https://inertia-rails.dev/guide/server-side-rendering)
128
+ - [Testing](https://inertia-rails.dev/guide/testing)
129
+ - [Configuration reference](https://inertia-rails.dev/guide/configuration)
223
130
 
224
- InertiaRails.configure do |config|
225
- # Example: force a full-reload if the deployed assets change.
226
- config.version = ViteRuby.digest
227
- end
228
- ```
131
+ ## Community
229
132
 
230
- The default configuration can be found [here](https://github.com/inertiajs/inertia-rails/blob/master/lib/inertia_rails/configuration.rb#L5-L22).
133
+ - [Awesome Inertia Rails](https://inertia-rails.dev/awesome) — gems, tutorials, and real-world apps
134
+ - [Discord](https://discord.gg/inertiajs) — ask questions, get answers fast
135
+ - [GitHub Discussions & Issues](https://github.com/inertiajs/inertia-rails/issues) — browse the source, report bugs
231
136
 
232
- ### Local Configuration
137
+ ## Contributing
233
138
 
234
- Use `inertia_config` in your controllers to override global settings:
139
+ Bug reports and pull requests are welcome. To run the test suite:
235
140
 
236
- ```ruby
237
- class EventsController < ApplicationController
238
- inertia_config(
239
- version: "events-#{InertiaRails.configuration.version}",
240
- ssr_enabled: -> { action_name == "index" },
241
- )
242
- end
141
+ ```bash
142
+ bundle install
143
+ bundle exec rspec
243
144
  ```
244
145
 
245
- ### Configuration Options
246
-
247
- #### `version` _(recommended)_
248
-
249
- This allows Inertia to detect if the app running in the client is oudated,
250
- forcing a full page visit instead of an XHR visit on the next request.
251
-
252
- See [assets versioning](https://inertiajs.com/asset-versioning).
253
-
254
- __Default__: `nil`
255
-
256
- #### `deep_merge_shared_data`
257
-
258
- When enabled, props will be deep merged with shared data, combining hashes
259
- with the same keys instead of replacing them.
260
-
261
- __Default__: `false`
146
+ See the [Code of Conduct](CODE_OF_CONDUCT.md). Everyone interacting with the
147
+ project is expected to follow it.
262
148
 
263
- #### `default_render`
264
-
265
- Overrides Rails default rendering behavior to render using Inertia by default.
266
-
267
- __Default__: `false`
268
-
269
- #### `encrypt_history`
270
-
271
- When enabled, you instruct Inertia to encrypt your app's history, it uses
272
- the browser's built-in [`crypto` api](https://developer.mozilla.org/en-US/docs/Web/API/Crypto)
273
- to encrypt the current page's data before pushing it to the history state.
274
-
275
- __Default__: `false`
276
-
277
- #### `ssr_enabled` _(experimental)_
278
-
279
- Whether to use a JavaScript server to pre-render your JavaScript pages,
280
- allowing your visitors to receive fully rendered HTML when they first visit
281
- your application.
282
-
283
- Requires a JS server to be available at `ssr_url`. [_Example_](https://github.com/ElMassimo/inertia-rails-ssr-template)
284
-
285
- __Default__: `false`
286
-
287
- #### `ssr_url` _(experimental)_
288
-
289
- The URL of the JS server that will pre-render the app using the specified
290
- component and props.
291
-
292
- __Default__: `"http://localhost:13714"`
293
-
294
- ## Testing
295
-
296
- If you're using Rspec, Inertia Rails comes with some nice test helpers to make things simple.
297
-
298
- To use these helpers, just add the following require statement to your `spec/rails_helper.rb`
299
-
300
- ```ruby
301
- require 'inertia_rails/rspec'
302
- ```
303
-
304
- And in any test you want to use the inertia helpers, add the inertia flag to the describe block
305
-
306
- ```ruby
307
- RSpec.describe EventController, type: :request do
308
- describe '#index', inertia: true do
309
- # ...
310
- end
311
- end
312
- ```
313
-
314
- ### Assertions
315
-
316
- ```ruby
317
- RSpec.describe EventController, type: :request do
318
- describe '#index', inertia: true do
319
-
320
- # check the component
321
- expect_inertia.to render_component 'Event/Index'
322
-
323
- # access the component name
324
- expect(inertia.component).to eq 'TestComponent'
325
-
326
- # props (including shared props)
327
- expect_inertia.to have_exact_props({name: 'Brandon', sport: 'hockey'})
328
- expect_inertia.to include_props({sport: 'hockey'})
329
-
330
- # access props
331
- expect(inertia.props[:name]).to eq 'Brandon'
332
-
333
- # view data
334
- expect_inertia.to have_exact_view_data({name: 'Brian', sport: 'basketball'})
335
- expect_inertia.to include_view_data({sport: 'basketball'})
336
-
337
- # access view data
338
- expect(inertia.view_data[:name]).to eq 'Brian'
339
-
340
- end
341
- end
342
-
343
- ```
149
+ ## Credits
344
150
 
345
- *Maintained and sponsored by the team at [bellaWatt](https://bellawatt.com/)*
151
+ Inertia Rails is part of the official [Inertia.js](https://inertiajs.com)
152
+ organization. It was originally created by the team at
153
+ [bellaWatt](https://bellawatt.com) and is maintained by the Inertia.js community.
346
154
 
347
- [![bellaWatt Logo](https://user-images.githubusercontent.com/6599653/114456832-5607d980-9bab-11eb-99c8-ab39867c384e.png)](https://bellawatt.com/)
155
+ Released under the [MIT License](LICENSE.txt).
@@ -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'])
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
 
@@ -223,7 +229,8 @@ module Inertia
223
229
  say_error 'Failed to install Vite Rails gem', :red
224
230
  exit(false)
225
231
  end
226
- if (capture = run('bundle exec vite install', capture: !verbose?))
232
+ vite_ruby_install_options = package_manager.present? ? "--package-manager=#{package_manager.name}" : ''
233
+ if (capture = run("bundle exec vite install #{vite_ruby_install_options}", capture: !verbose?))
227
234
  rename_application_js_to_ts if typescript?
228
235
  run('bundle binstub vite_ruby', capture: !verbose?) unless File.exist?(file_path('bin/vite'))
229
236
  say 'Vite Rails successfully installed', :green
@@ -282,8 +289,8 @@ module Inertia
282
289
  @package_manager ||= JSPackageManager.new(self)
283
290
  end
284
291
 
285
- def add_dependencies(*packages)
286
- package_manager.add_dependencies(*packages)
292
+ def add_dependencies(*packages, dev: false)
293
+ package_manager.add_dependencies(*packages, dev: dev)
287
294
  end
288
295
 
289
296
  def vite_config_path
@@ -15,10 +15,11 @@ module Inertia
15
15
  name.present?
16
16
  end
17
17
 
18
- def add_dependencies(*dependencies)
18
+ def add_dependencies(*dependencies, dev: false)
19
+ dev_flag = dev ? ' -D' : ''
19
20
  options = @generator.options[:verbose] ? '' : ' --silent'
20
21
  @generator.in_root do
21
- @generator.run "#{name} add #{dependencies.join(' ')}#{options}"
22
+ @generator.run "#{name} add#{dev_flag} #{dependencies.join(' ')}#{options}"
22
23
  end
23
24
  end
24
25
 
@@ -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.
@@ -14,10 +14,15 @@ module InertiaRails
14
14
  super(&block)
15
15
 
16
16
  @group = props[:group] || DEFAULT_GROUP
17
+ @rescue = props.fetch(:rescue, false)
17
18
  end
18
19
 
19
20
  def deferred?
20
21
  true
21
22
  end
23
+
24
+ def rescue?
25
+ @rescue
26
+ end
22
27
  end
23
28
  end
@@ -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
@@ -36,8 +36,14 @@ module InertiaRails
36
36
  id ||= config.root_dom_id
37
37
 
38
38
  if config.use_script_element_for_initial_page
39
+ script_options = { 'data-page': id, type: 'application/json' }
40
+ if respond_to?(:content_security_policy_nonce, true)
41
+ nonce = content_security_policy_nonce
42
+ script_options[:nonce] = nonce if nonce.present?
43
+ end
44
+
39
45
  safe_join([
40
- tag.script(page.to_json.html_safe, 'data-page': id, type: 'application/json'),
46
+ tag.script(page.to_json.html_safe, **script_options),
41
47
  tag.div(id: id)
42
48
  ], "\n")
43
49
  else
@@ -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?
@@ -24,6 +24,7 @@ module InertiaRails
24
24
  @_match_on = []
25
25
  @_once = {}
26
26
  @_scroll = {}
27
+ @_rescued = []
27
28
 
28
29
  props = expand_dot_notation(@props)
29
30
  resolved = deep_transform_props(props)
@@ -71,6 +72,7 @@ module InertiaRails
71
72
  metadata[:deepMergeProps] = @_deep_merge unless @_deep_merge.empty?
72
73
  metadata[:matchPropsOn] = @_match_on unless @_match_on.empty?
73
74
  metadata[:onceProps] = @_once unless @_once.empty?
75
+ metadata[:rescuedProps] = @_rescued unless @_rescued.empty?
74
76
 
75
77
  metadata
76
78
  end
@@ -99,29 +101,39 @@ module InertiaRails
99
101
  collect_metadata(prop, path)
100
102
  next unless keep_prop?(prop, path, parent_was_resolved: parent_was_resolved)
101
103
 
102
- value = @evaluator.call(prop)
104
+ rescue_enabled = prop.try(:rescue?)
103
105
 
104
- # A closure may return a prop type — unwrap one level
105
- if value.is_a?(BaseProp) && !prop.is_a?(BaseProp)
106
- collect_metadata(value, path)
107
- next unless keep_prop?(value, path, parent_was_resolved: parent_was_resolved)
106
+ begin
107
+ value = @evaluator.call(prop)
108
108
 
109
- value = @evaluator.call(value)
110
- end
109
+ # A closure may return a prop type — unwrap one level
110
+ if value.is_a?(BaseProp) && !prop.is_a?(BaseProp)
111
+ collect_metadata(value, path)
112
+ next unless keep_prop?(value, path, parent_was_resolved: parent_was_resolved)
111
113
 
112
- # A closure may return a Hash or Array containing prop types — recurse into it
113
- if prop.is_a?(Proc)
114
- if value.is_a?(Hash) && value.any?
115
- nested = deep_transform_props(value, path, parent_was_resolved: true)
116
- transformed_props[key] = nested unless nested.empty?
117
- next
118
- elsif value.is_a?(Array)
119
- transformed_props[key] = transform_array(value, path, parent_was_resolved: true)
120
- next
114
+ value = @evaluator.call(value)
121
115
  end
122
- end
123
116
 
124
- transformed_props[key] = value
117
+ # A closure may return a Hash or Array containing prop types — recurse into it
118
+ if prop.is_a?(Proc)
119
+ if value.is_a?(Hash) && value.any?
120
+ nested = deep_transform_props(value, path, parent_was_resolved: true)
121
+ transformed_props[key] = nested unless nested.empty?
122
+ next
123
+ elsif value.is_a?(Array)
124
+ transformed_props[key] = transform_array(value, path, parent_was_resolved: true)
125
+ next
126
+ end
127
+ end
128
+
129
+ transformed_props[key] = rescue_enabled ? value.as_json : value
130
+ rescue StandardError => e
131
+ raise unless rescue_enabled
132
+
133
+ report_rescued_error(e)
134
+ @_rescued << path
135
+ next
136
+ end
125
137
  end
126
138
  end
127
139
 
@@ -147,6 +159,16 @@ module InertiaRails
147
159
  end
148
160
  end
149
161
 
162
+ def report_rescued_error(error)
163
+ # `Rails.error` (the Error Reporter) was introduced in Rails 7.0. Fall back
164
+ # to the logger on older versions so rescued errors are never silently lost.
165
+ if Rails.respond_to?(:error)
166
+ Rails.error.report(error, handled: true)
167
+ else
168
+ Rails.logger&.error("[inertia-rails] Rescued deferred prop error: #{error.class}: #{error.message}")
169
+ end
170
+ end
171
+
150
172
  def collect_metadata(prop, path)
151
173
  return unless prop.is_a?(BaseProp)
152
174
 
@@ -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.1'
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.1
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