├── spec ├── dummy │ ├── log │ │ ├── .keep │ │ └── production.log │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── .ruby-version │ ├── app │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── application_controller.rb │ │ │ ├── inertia_child_share_test_controller.rb │ │ │ ├── inertia_responders_test_controller.rb │ │ │ ├── inertia_multithreaded_share_controller.rb │ │ │ ├── inertia_lambda_shared_props_controller.rb │ │ │ ├── inertia_session_continuity_test_controller.rb │ │ │ ├── inertia_share_test_controller.rb │ │ │ ├── inertia_merge_instance_props_controller.rb │ │ │ ├── inertia_config_test_controller.rb │ │ │ ├── inertia_conditional_sharing_controller.rb │ │ │ ├── inertia_rails_mimic_controller.rb │ │ │ ├── inertia_merge_shared_controller.rb │ │ │ ├── inertia_render_test_controller.rb │ │ │ └── inertia_test_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── testing.html.erb │ │ │ │ ├── conditional.html.erb │ │ │ │ └── application.html.erb │ │ └── javascript │ │ │ └── packs │ │ │ └── application.js │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── spring.rb │ │ ├── environment.rb │ │ ├── initializers │ │ │ ├── mime_types.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── application_controller_renderer.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── wrap_parameters.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── locales │ │ │ └── en.yml │ │ ├── application.rb │ │ ├── puma.rb │ │ ├── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ │ └── routes.rb │ ├── config.ru │ └── Rakefile ├── support │ └── helper_module.rb ├── spec_helper.rb ├── inertia │ ├── lazy_spec.rb │ ├── rails_mimic_spec.rb │ ├── error_sharing_spec.rb │ ├── middleware_spec.rb │ ├── conditional_sharing_spec.rb │ ├── response_spec.rb │ ├── ssr_spec.rb │ ├── sharing_spec.rb │ ├── rspec_helper_spec.rb │ ├── configuration_spec.rb │ ├── rendering_spec.rb │ └── request_spec.rb └── rails_helper.rb ├── .rspec ├── app ├── views │ └── inertia.html.erb └── controllers │ └── inertia_rails │ └── static_controller.rb ├── lib ├── inertia_rails │ ├── version.rb │ ├── helper.rb │ ├── engine.rb │ ├── inertia_rails.rb │ ├── lazy.rb │ ├── configuration.rb │ ├── middleware.rb │ ├── controller.rb │ ├── renderer.rb │ └── rspec.rb ├── patches │ ├── request.rb │ ├── mapper.rb │ ├── better_errors.rb │ ├── debug_exceptions.rb │ └── debug_exceptions │ │ ├── patch-5-0.rb │ │ └── patch-5-1.rb ├── generators │ └── inertia_rails │ │ ├── install │ │ ├── controller.rb │ │ ├── react │ │ │ ├── InertiaExample.jsx │ │ │ └── inertia.jsx │ │ ├── svelte │ │ │ ├── InertiaExample.svelte │ │ │ └── inertia.js │ │ └── vue │ │ │ ├── InertiaExample.vue │ │ │ └── inertia.js │ │ └── install_generator.rb ├── tasks │ └── inertia_rails.rake └── inertia_rails.rb ├── Rakefile ├── bin ├── setup └── console ├── Gemfile ├── .gitignore ├── .github └── workflows │ └── push.yml ├── LICENSE.txt ├── inertia_rails.gemspec ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /spec/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/log/production.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.3 2 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require rails_helper 4 | -------------------------------------------------------------------------------- /app/views/inertia.html.erb: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /lib/inertia_rails/version.rb: -------------------------------------------------------------------------------- 1 | module InertiaRails 2 | VERSION = "3.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/testing.html.erb: -------------------------------------------------------------------------------- 1 |

Testing Layout for configuration_spec

2 | <%= yield %> 3 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/conditional.html.erb: -------------------------------------------------------------------------------- 1 |

Conditional layout specified by controller

2 | <%= yield %> 3 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | <%= inertia_ssr_head %> 2 | <%= yield %> 3 | <%= local_assigns.except(:page, :inertia_ssr_head).to_json.html_safe %> 4 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in inertia-rails.gemspec 4 | gemspec 5 | 6 | version = ENV["RAILS_VERSION"] || "7.1" 7 | gem "rails", "~> #{version}.0" -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /app/controllers/inertia_rails/static_controller.rb: -------------------------------------------------------------------------------- 1 | module InertiaRails 2 | class StaticController < ::ApplicationController 3 | def static 4 | render inertia: params[:component] 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/patches/request.rb: -------------------------------------------------------------------------------- 1 | ActionDispatch::Request.class_eval do 2 | def inertia? 3 | key? 'HTTP_X_INERTIA' 4 | end 5 | 6 | def inertia_partial? 7 | key? 'HTTP_X_INERTIA_PARTIAL_DATA' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install/controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaExampleController < ApplicationController 2 | def index 3 | render inertia: 'InertiaExample', props: { 4 | name: 'World', 5 | } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install/react/InertiaExample.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const InertiaExample = ({name}) => ( 4 | <> 5 |

Hello {name}!

6 | 7 | ); 8 | 9 | export default InertiaExample; 10 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install/svelte/InertiaExample.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 |

10 | Hello {name}! 11 |

-------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 6 | -------------------------------------------------------------------------------- /lib/patches/mapper.rb: -------------------------------------------------------------------------------- 1 | ActionDispatch::Routing::Mapper.class_eval do 2 | def inertia(args, &block) 3 | route = args.keys.first 4 | component = args.values.first 5 | 6 | get(route => 'inertia_rails/static#static', defaults: {component: component}) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_child_share_test_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaChildShareTestController < InertiaShareTestController 2 | inertia_share name: 'No Longer Brandon' 3 | 4 | def share_with_inherited 5 | render inertia: 'ShareTestComponent' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install/vue/InertiaExample.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "pathname" 4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 5 | Pathname.new(__FILE__).realpath) 6 | 7 | require "rubygems" 8 | require "bundler/setup" 9 | require "rails/all" 10 | require "inertia_rails" 11 | 12 | require "irb" 13 | IRB.start(__FILE__) 14 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_responders_test_controller.rb: -------------------------------------------------------------------------------- 1 | require 'responders' 2 | 3 | class Thing 4 | end 5 | 6 | class InertiaRespondersTestController < ApplicationController 7 | self.responder = ActionController::Responder 8 | respond_to :html 9 | 10 | def redirect_test 11 | respond_with Thing.new, location: '/foo' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/helper_module.rb: -------------------------------------------------------------------------------- 1 | module HelperModule 2 | def with_forgery_protection 3 | orig = ActionController::Base.allow_forgery_protection 4 | begin 5 | ActionController::Base.allow_forgery_protection = true 6 | yield if block_given? 7 | ensure 8 | ActionController::Base.allow_forgery_protection = orig 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | # Enable flags like --only-failures and --next-failure 3 | config.example_status_persistence_file_path = ".rspec_status" 4 | 5 | # Disable RSpec exposing methods globally on `Module` and `main` 6 | config.disable_monkey_patching! 7 | 8 | config.expect_with :rspec do |c| 9 | c.syntax = :expect 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_multithreaded_share_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaMultithreadedShareController < ApplicationController 2 | inertia_share name: 'Michael' 3 | inertia_share has_goat_status: true 4 | 5 | def share_multithreaded 6 | sleep 1 7 | render inertia: 'ShareTestComponent' 8 | end 9 | 10 | def share_multithreaded_error 11 | raise Exception 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_lambda_shared_props_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaLambdaSharedPropsController < ApplicationController 2 | inertia_share someProperty: -> { 3 | { 4 | property_a: "some value", 5 | property_b: "this value" 6 | } 7 | } 8 | 9 | def lamda_shared_props 10 | render inertia: 'ShareTestComponent', props: { property_c: "some other value" } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/inertia_rails/helper.rb: -------------------------------------------------------------------------------- 1 | require_relative 'inertia_rails' 2 | 3 | module InertiaRails::Helper 4 | def inertia_ssr_head 5 | controller.instance_variable_get("@_inertia_ssr_head") 6 | end 7 | 8 | def inertia_headers 9 | InertiaRails.deprecator.warn( 10 | "`inertia_headers` is deprecated and will be removed in InertiaRails 4.0, use `inertia_ssr_head` instead." 11 | ) 12 | inertia_ssr_head 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_session_continuity_test_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaSessionContinuityTestController < ApplicationController 2 | def initialize_session 3 | render inertia: 'TestNewSessionComponent' 4 | end 5 | 6 | def submit_form_to_test_csrf 7 | render inertia: 'TestComponent' 8 | end 9 | 10 | def clear_session 11 | session.clear 12 | 13 | return redirect_to initialize_session_path 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_share_test_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaShareTestController < ApplicationController 2 | inertia_share name: 'Brandon' 3 | inertia_share sport: -> { 'hockey' } 4 | inertia_share do 5 | { 6 | position: 'center', 7 | number: number, 8 | } 9 | end 10 | 11 | def share 12 | render inertia: 'ShareTestComponent' 13 | end 14 | 15 | private 16 | 17 | def number 18 | 29 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /Gemfile.lock 10 | 11 | /spec/dummy/db/*.sqlite3 12 | /spec/dummy/db/*.sqlite3-journal 13 | /spec/dummy/db/log/*.log 14 | /spec/dummy/tmp/ 15 | /spec/dummy/.sass-cache 16 | /spec/dummy/log/ 17 | 18 | # rspec failure tracking 19 | .rspec_status 20 | 21 | # Appraisal 22 | gemfiles/*.gemfile.lock 23 | 24 | # Local files, such as .env.development.local 25 | *.local 26 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install/svelte/inertia.js: -------------------------------------------------------------------------------- 1 | import { createInertiaApp } from '@inertiajs/inertia-svelte' 2 | import { InertiaProgress } from '@inertiajs/progress' 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | InertiaProgress.init() 6 | 7 | createInertiaApp({ 8 | id: 'app', 9 | resolve: name => import(`../Pages/${name}.svelte`), 10 | setup({ el, App, props }) { 11 | new App({ target: el, props }) 12 | }, 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_merge_instance_props_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaMergeInstancePropsController < ApplicationController 2 | use_inertia_instance_props 3 | inertia_share do 4 | { 5 | nested: { 6 | points: 55, 7 | rebounds: 10, 8 | } 9 | } 10 | end 11 | 12 | def merge_instance_props 13 | @nested = { 14 | points: 100, 15 | } 16 | 17 | render inertia: 'InertiaTestComponent', deep_merge: true 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/patches/better_errors.rb: -------------------------------------------------------------------------------- 1 | # Patch BetterErrors::Middleware to render HTML for Inertia requests 2 | # 3 | # Original source: 4 | # https://github.com/BetterErrors/better_errors/blob/v2.5.1/lib/better_errors/middleware.rb 5 | # 6 | 7 | if defined?(BetterErrors) 8 | BetterErrors::Middleware.class_eval do 9 | prepend(InertiaBetterErrors = Module.new do 10 | def text?(env) 11 | return false if env["HTTP_X_INERTIA"] 12 | 13 | super 14 | end 15 | end) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/inertia_rails/engine.rb: -------------------------------------------------------------------------------- 1 | require_relative "middleware" 2 | require_relative "controller" 3 | 4 | module InertiaRails 5 | class Engine < ::Rails::Engine 6 | initializer "inertia_rails.configure_rails_initialization" do |app| 7 | app.middleware.use ::InertiaRails::Middleware 8 | end 9 | 10 | initializer "inertia_rails.action_controller" do 11 | ActiveSupport.on_load(:action_controller_base) do 12 | include ::InertiaRails::Controller 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_config_test_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaConfigTestController < ApplicationController 2 | inertia_config( 3 | deep_merge_shared_data: true, 4 | ssr_enabled: true, 5 | ssr_url: "http://localhost:7777", 6 | layout: "test", 7 | version: "1.0", 8 | ) 9 | 10 | # Test that modules included in the same class can also call it. 11 | inertia_config( 12 | version: "2.0", 13 | ) 14 | 15 | def configuration 16 | render json: inertia_configuration.send(:options) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/inertia_rails/inertia_rails.rb: -------------------------------------------------------------------------------- 1 | # Needed for `thread_mattr_accessor` 2 | require 'active_support/core_ext/module/attribute_accessors_per_thread' 3 | require 'inertia_rails/lazy' 4 | require 'inertia_rails/configuration' 5 | 6 | module InertiaRails 7 | CONFIGURATION = Configuration.default 8 | 9 | def self.configure 10 | yield(CONFIGURATION) 11 | end 12 | 13 | def self.configuration 14 | CONFIGURATION 15 | end 16 | 17 | def self.lazy(value = nil, &block) 18 | InertiaRails::Lazy.new(value, &block) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_conditional_sharing_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaConditionalSharingController < ApplicationController 2 | inertia_share normal_shared_prop: 1 3 | 4 | inertia_share do 5 | {conditionally_shared_show_prop: 1} if action_name == "show" 6 | end 7 | 8 | def index 9 | render inertia: 'EmptyTestComponent', props: { 10 | index_only_prop: 1, 11 | } 12 | end 13 | 14 | def show 15 | render inertia: 'EmptyTestComponent', props: { 16 | show_only_prop: 1, 17 | } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install/react/inertia.jsx: -------------------------------------------------------------------------------- 1 | import { App } from '@inertiajs/inertia-react'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | import { InertiaProgress } from '@inertiajs/progress'; 5 | 6 | document.addEventListener('DOMContentLoaded', () => { 7 | InertiaProgress.init(); 8 | const el = document.getElementById('app') 9 | 10 | render( 11 | require(`../Pages/${name}`).default} 14 | />, 15 | el 16 | ) 17 | }); 18 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install/vue/inertia.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import { app, plugin } from '@inertiajs/inertia-vue' 4 | import { InertiaProgress } from '@inertiajs/progress' 5 | 6 | document.addEventListener('DOMContentLoaded', () => { 7 | InertiaProgress.init(); 8 | const el = document.getElementById('app') 9 | 10 | Vue.use(plugin) 11 | 12 | new Vue({ 13 | render: h => h(app, { 14 | props: { 15 | initialPage: JSON.parse(el.dataset.page), 16 | resolveComponent: name => require(`../Pages/${name}`).default, 17 | }, 18 | }), 19 | }).$mount(el) 20 | }) 21 | -------------------------------------------------------------------------------- /spec/inertia/lazy_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe InertiaRails::Lazy do 2 | describe '#call' do 3 | context 'with a value' do 4 | it 'returns the value' do 5 | expect(InertiaRails::Lazy.new('thing').call).to eq('thing') 6 | end 7 | end 8 | 9 | context 'with a callable value' do 10 | it 'returns the result of the callable value' do 11 | expect(InertiaRails::Lazy.new(->{ 'thing' }).call).to eq('thing') 12 | end 13 | end 14 | 15 | context 'with a block' do 16 | it 'returns the result of the block' do 17 | expect(InertiaRails::Lazy.new{'thing'}.call).to eq('thing') 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tasks/inertia_rails.rake: -------------------------------------------------------------------------------- 1 | namespace :inertia_rails do 2 | namespace :install do 3 | desc "Installs inertia_rails packages and configurations for a React based app" 4 | task :react => :environment do 5 | system 'rails g inertia_rails:install --front_end react' 6 | end 7 | desc "Installs inertia_rails packages and configurations for a Vue based app" 8 | task vue: :environment do 9 | system 'rails g inertia_rails:install --front_end vue' 10 | end 11 | desc "Installs inertia_rails packages and configurations for a Svelte based app" 12 | task svelte: :environment do 13 | system 'rails g inertia_rails:install --front_end svelte' 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /lib/patches/debug_exceptions.rb: -------------------------------------------------------------------------------- 1 | # Patch ActionDispatch::DebugExceptions to render HTML for Inertia requests 2 | # 3 | # Rails has introduced text rendering for XHR requests with Rails 4.1 and 4 | # changed the implementation in 4.2, 5.0 and 5.1 (unchanged since then). 5 | # 6 | # The original source needs to be patched, so that Inertia requests are 7 | # NOT responded with plain text, but with HTML. 8 | 9 | if defined?(ActionDispatch::DebugExceptions) 10 | if ActionPack.version.to_s >= '5.1' 11 | require 'patches/debug_exceptions/patch-5-1' 12 | elsif ActionPack.version.to_s >= '5.0' 13 | require 'patches/debug_exceptions/patch-5-0' 14 | else 15 | # This gem supports Rails 5 or later 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_rails_mimic_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaRailsMimicController < ApplicationController 2 | inertia_config( 3 | default_render: -> { action_name == "default_render_test" }, 4 | ) 5 | use_inertia_instance_props 6 | 7 | def instance_props_test 8 | @name = 'Brandon' 9 | @sport = 'hockey' 10 | 11 | render inertia: 'TestComponent' 12 | end 13 | 14 | def default_render_test 15 | @name = 'Brian' 16 | end 17 | 18 | def provided_props_test 19 | @name = 'Brian' 20 | 21 | render inertia: 'TestComponent', props: { 22 | sport: 'basketball', 23 | } 24 | end 25 | 26 | def default_component_test 27 | render inertia: true 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /lib/inertia_rails.rb: -------------------------------------------------------------------------------- 1 | require 'inertia_rails/renderer' 2 | require 'inertia_rails/engine' 3 | 4 | require 'patches/debug_exceptions' 5 | require 'patches/better_errors' 6 | require 'patches/request' 7 | require 'patches/mapper' 8 | 9 | ActionController::Renderers.add :inertia do |component, options| 10 | InertiaRails::Renderer.new( 11 | component, 12 | self, 13 | request, 14 | response, 15 | method(:render), 16 | props: options[:props], 17 | view_data: options[:view_data], 18 | deep_merge: options[:deep_merge], 19 | ).render 20 | end 21 | 22 | module InertiaRails 23 | class Error < StandardError; end 24 | 25 | def self.deprecator # :nodoc: 26 | @deprecator ||= ActiveSupport::Deprecation.new 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/dummy/app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require_tree . 15 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_merge_shared_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaMergeSharedController < ApplicationController 2 | inertia_share do 3 | { 4 | nested: { 5 | goals: 100, 6 | assists: 100, 7 | } 8 | } 9 | end 10 | 11 | def merge_shared 12 | render inertia: 'ShareTestComponent', props: { 13 | nested: { 14 | assists: 200, 15 | } 16 | } 17 | end 18 | 19 | def deep_merge_shared 20 | render inertia: 'ShareTestComponent', props: { 21 | nested: { 22 | assists: 300, 23 | } 24 | }, deep_merge: true 25 | end 26 | 27 | def shallow_merge_shared 28 | render inertia: 'ShareTestComponent', props: { 29 | nested: { 30 | assists: 200, 31 | } 32 | }, deep_merge: false 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/inertia_rails/lazy.rb: -------------------------------------------------------------------------------- 1 | module InertiaRails 2 | class Lazy 3 | def initialize(value = nil, &block) 4 | @value = value 5 | @block = block 6 | end 7 | 8 | def call 9 | to_proc.call 10 | end 11 | 12 | def to_proc 13 | # This is called by controller.instance_exec, which changes self to the 14 | # controller instance. That makes the instance variables unavailable to the 15 | # proc via closure. Copying the instance variables to local variables before 16 | # the proc is returned keeps them in scope for the returned proc. 17 | value = @value 18 | block = @block 19 | if value.respond_to?(:call) 20 | value 21 | elsif value 22 | Proc.new { value } 23 | else 24 | block 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | ruby: ['3.1', '3.2', '3.3'] 11 | rails: ['6.1', '7.0', '7.1'] 12 | 13 | runs-on: ubuntu-latest 14 | name: Test against Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup System 20 | run: sudo apt-get install libsqlite3-dev 21 | 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | env: 28 | RAILS_VERSION: ${{ matrix.rails }} 29 | 30 | - name: Run tests 31 | run: bundle exec rake 32 | env: 33 | RAILS_VERSION: ${{ matrix.rails }} 34 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_render_test_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaRenderTestController < ApplicationController 2 | 3 | def props 4 | render inertia: 'TestComponent', props: { 5 | name: 'Brandon', 6 | sport: -> { 'hockey' } 7 | } 8 | end 9 | 10 | def view_data 11 | render inertia: 'TestComponent', view_data: { 12 | name: 'Brian', 13 | sport: 'basketball', 14 | } 15 | end 16 | 17 | def component 18 | render inertia: 'TestComponent' 19 | end 20 | 21 | def vary_header 22 | response.headers["Vary"] = 'Accept-Language' 23 | 24 | render inertia: 'TestComponent' 25 | end 26 | 27 | def lazy_props 28 | render inertia: 'TestComponent', props: { 29 | name: 'Brian', 30 | sport: InertiaRails.lazy('basketball'), 31 | level: InertiaRails.lazy do 32 | 'worse than he believes' 33 | end, 34 | grit: InertiaRails.lazy(->{ 'intense' }) 35 | } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/patches/debug_exceptions/patch-5-0.rb: -------------------------------------------------------------------------------- 1 | # Patch ActionDispatch::DebugExceptions to render HTML for Inertia requests 2 | # 3 | # Original source: 4 | # https://github.com/rails/rails/blob/5-0-stable/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb 5 | # 6 | 7 | ActionDispatch::DebugExceptions.class_eval do 8 | prepend(InertiaDebugExceptions = Module.new do 9 | def render_for_default_application(request, wrapper) 10 | template = create_template(request, wrapper) 11 | file = "rescues/#{wrapper.rescue_template}" 12 | 13 | if request.xhr? && !request.headers['X-Inertia'] # <<<< this line is changed only 14 | body = template.render(template: file, layout: false, formats: [:text]) 15 | format = "text/plain" 16 | else 17 | body = template.render(template: file, layout: 'rescues/layout') 18 | format = "text/html" 19 | end 20 | render(wrapper.status_code, body, format) 21 | end 22 | end) 23 | end 24 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to setup or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:prepare' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 TODO: Bellawatt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/patches/debug_exceptions/patch-5-1.rb: -------------------------------------------------------------------------------- 1 | # Patch ActionDispatch::DebugExceptions to render HTML for Inertia requests 2 | # 3 | # Original source (unchanged since Rails 5.1): 4 | # https://github.com/rails/rails/blob/5-1-stable/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb 5 | # https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb 6 | # https://github.com/rails/rails/blob/6-0-stable/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb 7 | # 8 | 9 | ActionDispatch::DebugExceptions.class_eval do 10 | prepend(InertiaDebugExceptions = Module.new do 11 | def render_for_browser_request(request, wrapper) 12 | template = create_template(request, wrapper) 13 | file = "rescues/#{wrapper.rescue_template}" 14 | 15 | if request.xhr? && !request.headers['X-Inertia'] # <<<< this line is changed only 16 | body = template.render(template: file, layout: false, formats: [:text]) 17 | format = "text/plain" 18 | else 19 | body = template.render(template: file, layout: "rescues/layout") 20 | format = "text/html" 21 | end 22 | 23 | render(wrapper.status_code, body, format) 24 | end 25 | end) 26 | end 27 | -------------------------------------------------------------------------------- /spec/inertia/rails_mimic_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/inertia_rails/rspec' 2 | 3 | RSpec.describe 'rendering when mimicking rails behavior', type: :request, inertia: true do 4 | 5 | context 'the props are provided by instance variables' do 6 | it 'has the props' do 7 | get instance_props_test_path 8 | 9 | expect_inertia.to have_exact_props({'name' => 'Brandon', 'sport' => 'hockey'}) 10 | end 11 | end 12 | 13 | context 'props are explicitly provided' do 14 | it 'only includes the provided props' do 15 | get provided_props_test_path 16 | 17 | expect_inertia.to have_exact_props({'sport': 'basketball'}) 18 | end 19 | end 20 | 21 | context 'no component name is provided' do 22 | it 'has the correct derived component name' do 23 | get default_component_test_path 24 | 25 | expect_inertia.to render_component('inertia_rails_mimic/default_component_test') 26 | end 27 | end 28 | 29 | context 'no render is done at all and default_render is enabled' do 30 | it 'renders via inertia' do 31 | get default_render_test_path 32 | 33 | expect_inertia.to render_component('inertia_rails_mimic/default_render_test') 34 | expect_inertia.to include_props({'name' => 'Brian'}) 35 | end 36 | end 37 | end 38 | 39 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | # require "active_model/railtie" 6 | # require "active_job/railtie" 7 | # require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | # require "action_view/railtie" 14 | # require "action_cable/engine" 15 | # require "sprockets/railtie" 16 | # require "rails/test_unit/railtie" 17 | 18 | Bundler.require(*Rails.groups) 19 | require "inertia_rails" 20 | 21 | module Dummy 22 | class Application < Rails::Application 23 | if Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new('5.1.0') 24 | # Initialize configuration defaults for current Rails version. 25 | config.load_defaults "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}" 26 | end 27 | 28 | # Settings in config/environments/* take precedence over those specified here. 29 | # Application configuration can go into files in config/initializers 30 | # -- all .rb files in that directory are automatically loaded after loading 31 | # the framework and any gems in your application. 32 | 33 | # Required for Rails 5.0 and 5.1 34 | config.secret_key_base = SecureRandom.hex 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | 19 | # If you are using UJS then enable automatic nonce generation 20 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 21 | 22 | # Set the nonce only to specific directives 23 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 24 | 25 | # Report CSP violations to a specified URI 26 | # For further information see the following documentation: 27 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 28 | # Rails.application.config.content_security_policy_report_only = true 29 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 12 | # 13 | port ENV.fetch("PORT") { 3000 } 14 | 15 | # Specifies the `environment` that Puma will run in. 16 | # 17 | environment ENV.fetch("RAILS_ENV") { "development" } 18 | 19 | # Specifies the `pidfile` that Puma will use. 20 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 21 | 22 | # Specifies the number of `workers` to boot in clustered mode. 23 | # Workers are forked web server processes. If using threads and workers together 24 | # the concurrency of the application would be max `threads` * `workers`. 25 | # Workers do not work on JRuby or Windows (both of which do not support 26 | # processes). 27 | # 28 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 29 | 30 | # Use the `preload_app!` method when specifying a `workers` number. 31 | # This directive tells Puma to first boot the application and load code 32 | # before forking the application. This takes advantage of Copy On Write 33 | # process behavior so workers use less memory. 34 | # 35 | # preload_app! 36 | 37 | # Allow puma to be restarted by `rails restart` command. 38 | plugin :tmp_restart 39 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/inertia_test_controller.rb: -------------------------------------------------------------------------------- 1 | class InertiaTestController < ApplicationController 2 | layout 'conditional', only: [:with_different_layout] 3 | 4 | def empty_test 5 | render inertia: 'EmptyTestComponent' 6 | end 7 | 8 | def with_different_layout 9 | render inertia: 'EmptyTestComponent' 10 | end 11 | 12 | def redirect_test 13 | redirect_to :empty_test 14 | end 15 | 16 | def inertia_request_test 17 | if request.inertia? 18 | head 202 19 | else 20 | head 200 21 | end 22 | end 23 | 24 | def inertia_partial_request_test 25 | if request.inertia_partial? 26 | head 202 27 | else 28 | head 200 29 | end 30 | end 31 | 32 | def non_inertiafied 33 | render plain: 'hey' 34 | end 35 | 36 | # Calling it my_location to avoid this in Rails 5.0 37 | # https://github.com/rails/rails/issues/28033 38 | def my_location 39 | inertia_location empty_test_path 40 | end 41 | 42 | def redirect_with_inertia_errors 43 | redirect_to empty_test_path, inertia: { errors: { uh: 'oh' } } 44 | end 45 | 46 | def redirect_back_with_inertia_errors 47 | redirect_back( 48 | fallback_location: empty_test_path, 49 | inertia: { errors: { go: 'back!' } } 50 | ) 51 | end 52 | 53 | def error_404 54 | render inertia: 'ErrorComponent', status: 404 55 | end 56 | 57 | def error_500 58 | render inertia: 'ErrorComponent', status: 500 59 | end 60 | 61 | def content_type_test 62 | respond_to do |format| 63 | format.html { render inertia: 'EmptyTestComponent' } 64 | format.xml { render xml: [ 1, 2, 3 ] } 65 | end 66 | end 67 | 68 | def redirect_to_share_test 69 | redirect_to share_path 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /inertia_rails.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "inertia_rails/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "inertia_rails" 7 | spec.version = InertiaRails::VERSION 8 | spec.authors = ["Brian Knoles", "Brandon Shar", "Eugene Granovsky"] 9 | spec.email = ["brain@bellawatt.com", "brandon@bellawatt.com", "eugene@bellawatt.com"] 10 | 11 | spec.summary = %q{Inertia adapter for Rails} 12 | spec.homepage = "https://github.com/inertiajs/inertia-rails" 13 | spec.license = "MIT" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = spec.homepage 17 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 23 | end 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | 28 | spec.add_runtime_dependency "railties", '>= 5' 29 | 30 | spec.add_development_dependency "bundler", "~> 2.0" 31 | spec.add_development_dependency "rake", "~> 13.0" 32 | spec.add_development_dependency "rspec-rails", "~> 4.0" 33 | spec.add_development_dependency "rails-controller-testing" 34 | spec.add_development_dependency "sqlite3" 35 | spec.add_development_dependency "responders" 36 | spec.add_development_dependency "debug" 37 | end 38 | -------------------------------------------------------------------------------- /spec/inertia/error_sharing_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'errors shared automatically', type: :request do 2 | context 'rendering errors across redirects' do 3 | let(:server_version){ 1.0 } 4 | let(:headers){ { 'X-Inertia' => true, 'X-Inertia-Version' => server_version } } 5 | 6 | before { InertiaRails.configure{|c| c.version = server_version} } 7 | after { InertiaRails.configure{|c| c.version = nil } } 8 | 9 | it 'automatically renders errors in inertia' do 10 | post redirect_with_inertia_errors_path, headers: headers 11 | expect(response.headers['Location']).to eq(empty_test_url) 12 | expect(session[:inertia_errors]).to include({ uh: 'oh' }) 13 | 14 | # Follow the redirect 15 | get response.headers['Location'], headers: headers 16 | expect(response.body).to include({ errors: { uh: 'oh' } }.to_json) 17 | expect(session[:inertia_errors]).not_to be 18 | end 19 | 20 | it 'keeps errors around when the post has a stale version' do 21 | post redirect_with_inertia_errors_path, headers: headers 22 | expect(response.headers['Location']).to eq(empty_test_url) 23 | expect(session[:inertia_errors]).to include({ uh: 'oh' }) 24 | 25 | # Simulate that the POST was using a stale version 26 | get empty_test_path, headers: headers.merge({ 'X-Inertia-Version' => 'stale' }) 27 | expect(response.status).to eq(409) 28 | # Inertia errors are _not_ deleted 29 | expect(session[:inertia_errors]).to include({ uh: 'oh' }.as_json) 30 | 31 | # Simulate the page refresh that Inertia triggers in response to a 409 32 | get empty_test_path 33 | expect(response.body).to include(CGI::escape_html({ errors: { uh: 'oh' } }.to_json)) 34 | expect(session[:inertia_errors]).not_to be 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | if Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new('6.1.0') 2 | require 'active_support/core_ext/integer/time' 3 | end 4 | 5 | # The test environment is used exclusively to run your application's 6 | # test suite. You never need to work with it otherwise. Remember that 7 | # your test database is "scratch space" for the test suite and is wiped 8 | # and recreated between test runs. Don't rely on the data there! 9 | 10 | Rails.application.configure do 11 | # Settings specified here will take precedence over those in config/application.rb. 12 | 13 | config.cache_classes = false 14 | 15 | # Do not eager load code on boot. This avoids loading your whole application 16 | # just for the purpose of running a single test. If you are using a tool that 17 | # preloads Rails for running tests, you may have to set it to true. 18 | config.eager_load = false 19 | 20 | # Configure public file server for tests with Cache-Control for performance. 21 | config.public_file_server.enabled = true 22 | config.public_file_server.headers = { 23 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 24 | } 25 | 26 | # Show full error reports and disable caching. 27 | config.consider_all_requests_local = true 28 | config.action_controller.perform_caching = false 29 | config.cache_store = :null_store 30 | 31 | # Raise exceptions instead of rendering exception templates. 32 | config.action_dispatch.show_exceptions = :none 33 | 34 | # Disable request forgery protection in test environment. 35 | config.action_controller.allow_forgery_protection = false 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations. 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /spec/inertia/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe InertiaRails::Middleware, type: :request do 2 | context 'the version is stale' do 3 | it 'tells the client to refresh' do 4 | get empty_test_path, headers: {'X-Inertia' => true, 'X-Inertia-Version' => 'blkajdf'} 5 | 6 | expect(response.status).to eq 409 7 | expect(response.headers['X-Inertia-Location']).to eq request.original_url 8 | end 9 | end 10 | 11 | context 'a redirect status was passed with an http method that preserves itself on 302 redirect' do 12 | subject { response.status } 13 | 14 | context 'PUT' do 15 | before { put redirect_test_path, headers: {'X-Inertia' => true} } 16 | 17 | it { is_expected.to eq 303 } 18 | end 19 | 20 | context 'PATCH' do 21 | before { patch redirect_test_path, headers: {'X-Inertia' => true} } 22 | 23 | it { is_expected.to eq 303 } 24 | end 25 | 26 | context 'DELETE' do 27 | before { delete redirect_test_path, headers: {'X-Inertia' => true} } 28 | 29 | it { is_expected.to eq 303 } 30 | end 31 | 32 | it 'is thread safe' do 33 | delete_request_proc = -> { delete redirect_test_path, headers: { 'X-Inertia' => true } } 34 | get_request_proc = -> { get empty_test_path } 35 | 36 | statusses = [] 37 | 38 | threads = [] 39 | 40 | 100.times do 41 | threads << Thread.new { statusses << delete_request_proc.call } 42 | threads << Thread.new { get_request_proc.call } 43 | end 44 | 45 | threads.each(&:join) 46 | 47 | expect(statusses.uniq).to eq([303]) 48 | end 49 | end 50 | 51 | context 'a request not originating from inertia' do 52 | it 'is ignored' do 53 | get empty_test_path, headers: {'X-Inertia-Version' => 'blkajdf'} 54 | 55 | expect(response.status).to eq 200 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/inertia/conditional_sharing_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "conditionally shared data in a controller", type: :request do 2 | context "when there is conditional data inside inertia_share" do 3 | it "does not leak data between requests" do 4 | get conditional_share_index_path, headers: {'X-Inertia' => true} 5 | expect(JSON.parse(response.body)['props'].deep_symbolize_keys).to eq({ 6 | index_only_prop: 1, 7 | normal_shared_prop: 1, 8 | }) 9 | 10 | # NOTE: we actually have to run the show action twice since the new implementation 11 | # sets up a before_action within a before_action to share the data. 12 | # In effect, that means that the shared data isn't rendered until the second time the action is run. 13 | get conditional_share_show_path, headers: {'X-Inertia' => true} 14 | get conditional_share_show_path, headers: {'X-Inertia' => true} 15 | expect(JSON.parse(response.body)['props'].deep_symbolize_keys).to eq({ 16 | normal_shared_prop: 1, 17 | show_only_prop: 1, 18 | conditionally_shared_show_prop: 1, 19 | }) 20 | 21 | get conditional_share_index_path, headers: {'X-Inertia' => true} 22 | expect(JSON.parse(response.body)['props'].deep_symbolize_keys).to eq({ 23 | index_only_prop: 1, 24 | normal_shared_prop: 1, 25 | }) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/inertia/response_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'InertiaRails::Response', type: :request do 2 | describe 'inertia location response' do 3 | it 'returns an inertia location response' do 4 | get my_location_path 5 | 6 | expect(response.status).to eq 409 7 | expect(response.headers['X-Inertia-Location']).to eq empty_test_path 8 | end 9 | end 10 | 11 | describe 'redirect_to' do 12 | context 'with an [:inertia][:errors] option' do 13 | # In practice, a GET -> redirect + errors probably shouldn't happen 14 | context 'with a get request' do 15 | it 'adds :inertia_errors to the session' do 16 | get redirect_with_inertia_errors_path 17 | expect(response.status).to eq 302 18 | expect(response.headers['Location']).to eq(empty_test_url) 19 | expect(session[:inertia_errors]).to include({ uh: 'oh' }) 20 | end 21 | end 22 | 23 | context 'with a post request' do 24 | it 'adds :inertia_errors to the session' do 25 | post redirect_with_inertia_errors_path, headers: { 'X-Inertia' => true } 26 | expect(response.status).to eq 302 27 | expect(response.headers['Location']).to eq(empty_test_url) 28 | expect(session[:inertia_errors]).to include({ uh: 'oh' }) 29 | end 30 | end 31 | end 32 | end 33 | 34 | describe 'redirect_back' do 35 | context 'with an [:inertia][:errors] option' do 36 | context 'with a post request' do 37 | it 'adds :inertia_errors to the session' do 38 | post( 39 | redirect_back_with_inertia_errors_path, 40 | headers: { 41 | 'X-Inertia' => true, 42 | 'HTTP_REFERER' => "http://example.com/current-path" 43 | } 44 | ) 45 | expect(response.status).to eq 302 46 | expect(response.headers['Location']).to eq('http://example.com/current-path') 47 | expect(session[:inertia_errors]).to include({ go: 'back!' }) 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | config.action_controller.enable_fragment_cache_logging = true 20 | 21 | config.cache_store = :memory_store 22 | config.public_file_server.headers = { 23 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 24 | } 25 | else 26 | config.action_controller.perform_caching = false 27 | 28 | config.cache_store = :null_store 29 | end 30 | 31 | # Print deprecation notices to the Rails logger. 32 | config.active_support.deprecation = :log 33 | 34 | # Raise an error on page load if there are pending migrations. 35 | config.active_record.migration_error = :page_load 36 | 37 | # Highlight code that triggered database queries in logs. 38 | config.active_record.verbose_query_logs = true 39 | 40 | # Debug mode disables concatenation and preprocessing of assets. 41 | # This option may cause significant delays in view rendering with a large 42 | # number of complex assets. 43 | config.assets.debug = true 44 | 45 | # Suppress logger output for asset requests. 46 | config.assets.quiet = true 47 | 48 | # Raises error for missing translations. 49 | # config.action_view.raise_on_missing_translations = true 50 | 51 | # Use an evented file watcher to asynchronously detect changes in source code, 52 | # routes, locales, etc. This feature depends on the listen gem. 53 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 54 | end 55 | -------------------------------------------------------------------------------- /lib/inertia_rails/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module InertiaRails 4 | class Configuration 5 | DEFAULTS = { 6 | # Whether to combine hashes with the same keys instead of replacing them. 7 | deep_merge_shared_data: false, 8 | 9 | # Overrides Rails default rendering behavior to render using Inertia by default. 10 | default_render: false, 11 | 12 | # DEPRECATED: Let Rails decide which layout should be used based on the 13 | # controller configuration. 14 | layout: true, 15 | 16 | # SSR options. 17 | ssr_enabled: false, 18 | ssr_url: 'http://localhost:13714', 19 | 20 | # Used to detect version drift between server and client. 21 | version: nil, 22 | }.freeze 23 | 24 | OPTION_NAMES = DEFAULTS.keys.freeze 25 | 26 | protected attr_reader :controller 27 | protected attr_reader :options 28 | 29 | def initialize(controller: nil, **attrs) 30 | @controller = controller 31 | @options = attrs.extract!(*OPTION_NAMES) 32 | 33 | unless attrs.empty? 34 | raise ArgumentError, "Unknown options for #{self.class}: #{attrs.keys}" 35 | end 36 | end 37 | 38 | def bind_controller(controller) 39 | Configuration.new(**@options, controller: controller) 40 | end 41 | 42 | def freeze 43 | @options.freeze 44 | super 45 | end 46 | 47 | def merge!(config) 48 | @options.merge!(config.options) 49 | self 50 | end 51 | 52 | def merge(config) 53 | Configuration.new(**@options.merge(config.options)) 54 | end 55 | 56 | # Internal: Finalizes the configuration for a specific controller. 57 | def with_defaults(config) 58 | @options = config.options.merge(@options) 59 | freeze 60 | end 61 | 62 | OPTION_NAMES.each do |option| 63 | define_method(option) { 64 | evaluate_option @options[option] 65 | } 66 | define_method("#{option}=") { |value| 67 | @options[option] = value 68 | } 69 | end 70 | 71 | def self.default 72 | new(**DEFAULTS) 73 | end 74 | 75 | private 76 | 77 | def evaluate_option(value) 78 | return value unless value.respond_to?(:call) 79 | return value.call unless controller 80 | controller.instance_exec(&value) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/inertia/ssr_spec.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | 3 | RSpec.describe 'inertia ssr', type: :request do 4 | context 'ssr is enabled' do 5 | before do 6 | InertiaRails.configure do |config| 7 | config.ssr_enabled = true 8 | config.ssr_url = 'ssr-url' 9 | config.version = '1.0' 10 | end 11 | end 12 | 13 | context 'with a successful ssr response' do 14 | before do 15 | allow(Net::HTTP).to receive(:post) 16 | .with( 17 | URI('ssr-url/render'), 18 | { 19 | component: 'TestComponent', 20 | props: {name: 'Brandon', sport: 'hockey'}, 21 | url: props_path, 22 | version: '1.0', 23 | }.to_json, 24 | 'Content-Type' => 'application/json' 25 | ) 26 | .and_return(OpenStruct.new( 27 | body: { 28 | body: '
Test works
', 29 | head: ['Title works'], 30 | }.to_json 31 | )) 32 | end 33 | 34 | it 'returns the result of the ssr call' do 35 | get props_path 36 | 37 | expect(response.body).to include('Title works') 38 | expect(response.body).to include('
Test works
') 39 | expect(response.headers['Content-Type']).to eq 'text/html; charset=utf-8' 40 | end 41 | 42 | it 'allows inertia to take over when inertia headers are passed' do 43 | get props_path, headers: {'X-Inertia' => true, 'X-Inertia-Version' => '1.0'} 44 | 45 | expect(response.headers['Vary']).to eq 'X-Inertia' 46 | expect(response.headers['Content-Type']).to eq 'application/json; charset=utf-8' 47 | end 48 | end 49 | 50 | context 'the ssr server fails for some reason' do 51 | before do 52 | allow(Net::HTTP).to receive(:post) 53 | .with( 54 | URI('ssr-url/render'), 55 | { 56 | component: 'TestComponent', 57 | props: {name: 'Brandon', sport: 'hockey'}, 58 | url: props_path, 59 | version: '1.0', 60 | }.to_json, 61 | 'Content-Type' => 'application/json' 62 | ) 63 | .and_raise('uh oh') 64 | end 65 | 66 | it 'renders inertia without ssr as a fallback' do 67 | get props_path 68 | 69 | expect(response.body).to include '
' 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | ENV['RAILS_ENV'] ||= 'test' 3 | 4 | require File.expand_path('../dummy/config/environment', __FILE__) 5 | 6 | # Allow using `debugger` to debug failing tests. 7 | require 'debug' 8 | 9 | # Prevent database truncation if the environment is production 10 | abort("The Rails environment is running in production mode!") if Rails.env.production? 11 | require 'rspec/rails' 12 | # Require the spec/support directory and its subdirectories. 13 | Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } 14 | 15 | require_relative './support/helper_module' 16 | # Add additional requires below this line. Rails is not loaded until this point! 17 | # Requires supporting ruby files with custom matchers and macros, etc, in 18 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 19 | # run as spec files by default. This means that files in spec/support that end 20 | # in _spec.rb will both be required and run as specs, causing the specs to be 21 | # run twice. It is recommended that you do not name files matching this glob to 22 | # end with _spec.rb. You can configure this pattern with the --pattern 23 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 24 | # 25 | # The following line is provided for convenience purposes. It has the downside 26 | # of increasing the boot-up time by auto-requiring all files in the support 27 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 28 | # require only the support files necessary. 29 | # 30 | # Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } 31 | 32 | RSpec.configure do |config| 33 | # RSpec Rails can automatically mix in different behaviours to your tests 34 | # based on their file location, for example enabling you to call `get` and 35 | # `post` in specs under `spec/controllers`. 36 | # 37 | # You can disable this behaviour by removing the line below, and instead 38 | # explicitly tag your specs with their type, e.g.: 39 | # 40 | # RSpec.describe UsersController, :type => :controller do 41 | # # ... 42 | # end 43 | # 44 | # The different available types are documented in the features, such as in 45 | # https://relishapp.com/rspec/rspec-rails/docs 46 | config.infer_spec_type_from_file_location! 47 | 48 | # Filter lines from Rails gems in backtraces. 49 | config.filter_rails_from_backtrace! 50 | # arbitrary gems may also be filtered via: 51 | # config.filter_gems_from_backtrace("gem name") 52 | 53 | config.include HelperModule 54 | end 55 | 56 | require 'rails-controller-testing' 57 | Rails::Controller::Testing.install 58 | -------------------------------------------------------------------------------- /lib/inertia_rails/middleware.rb: -------------------------------------------------------------------------------- 1 | module InertiaRails 2 | class Middleware 3 | def initialize(app) 4 | @app = app 5 | end 6 | 7 | def call(env) 8 | InertiaRailsRequest.new(@app, env).response 9 | end 10 | 11 | class InertiaRailsRequest 12 | def initialize(app, env) 13 | @app = app 14 | @env = env 15 | end 16 | 17 | def response 18 | copy_xsrf_to_csrf! 19 | status, headers, body = @app.call(@env) 20 | request = ActionDispatch::Request.new(@env) 21 | 22 | # Inertia errors are added to the session via redirect_to 23 | request.session.delete(:inertia_errors) unless keep_inertia_errors?(status) 24 | 25 | status = 303 if inertia_non_post_redirect?(status) 26 | 27 | stale_inertia_get? ? force_refresh(request) : [status, headers, body] 28 | end 29 | 30 | private 31 | 32 | def keep_inertia_errors?(status) 33 | redirect_status?(status) || stale_inertia_request? 34 | end 35 | 36 | def stale_inertia_request? 37 | inertia_request? && version_stale? 38 | end 39 | 40 | def redirect_status?(status) 41 | [301, 302].include? status 42 | end 43 | 44 | def non_get_redirectable_method? 45 | ['PUT', 'PATCH', 'DELETE'].include? request_method 46 | end 47 | 48 | def inertia_non_post_redirect?(status) 49 | inertia_request? && redirect_status?(status) && non_get_redirectable_method? 50 | end 51 | 52 | def stale_inertia_get? 53 | get? && stale_inertia_request? 54 | end 55 | 56 | def get? 57 | request_method == 'GET' 58 | end 59 | 60 | def controller 61 | @env["action_controller.instance"] 62 | end 63 | 64 | def request_method 65 | @env['REQUEST_METHOD'] 66 | end 67 | 68 | def client_version 69 | @env['HTTP_X_INERTIA_VERSION'] 70 | end 71 | 72 | def inertia_request? 73 | @env['HTTP_X_INERTIA'].present? 74 | end 75 | 76 | def version_stale? 77 | coerce_version(client_version) != coerce_version(server_version) 78 | end 79 | 80 | def server_version 81 | controller&.send(:inertia_configuration)&.version 82 | end 83 | 84 | def coerce_version(version) 85 | server_version.is_a?(Numeric) ? version.to_f : version 86 | end 87 | 88 | def force_refresh(request) 89 | request.flash.keep 90 | Rack::Response.new('', 409, {'X-Inertia-Location' => request.original_url}).finish 91 | end 92 | 93 | def copy_xsrf_to_csrf! 94 | @env['HTTP_X_CSRF_TOKEN'] = @env['HTTP_X_XSRF_TOKEN'] if @env['HTTP_X_XSRF_TOKEN'] && inertia_request? 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount InertiaRails::Engine => "/inertia-rails" 3 | 4 | get 'configuration' => 'inertia_config_test#configuration' 5 | get 'props' => 'inertia_render_test#props' 6 | get 'view_data' => 'inertia_render_test#view_data' 7 | get 'component' => 'inertia_render_test#component' 8 | get 'vary_header' => 'inertia_render_test#vary_header' 9 | get 'share' => 'inertia_share_test#share' 10 | get 'share_with_inherited' => 'inertia_child_share_test#share_with_inherited' 11 | get 'empty_test' => 'inertia_test#empty_test' 12 | get 'with_different_layout' => 'inertia_test#with_different_layout' 13 | get 'redirect_test' => 'inertia_test#redirect_test' 14 | get 'inertia_request_test' => 'inertia_test#inertia_request_test' 15 | get 'inertia_partial_request_test' => 'inertia_test#inertia_partial_request_test' 16 | post 'redirect_with_responders' => 'inertia_responders_test#redirect_test' 17 | post 'redirect_test' => 'inertia_test#redirect_test' 18 | patch 'redirect_test' => 'inertia_test#redirect_test' 19 | put 'redirect_test' => 'inertia_test#redirect_test' 20 | delete 'redirect_test' => 'inertia_test#redirect_test' 21 | get 'my_location' => 'inertia_test#my_location' 22 | get 'share_multithreaded' => 'inertia_multithreaded_share#share_multithreaded' 23 | get 'share_multithreaded_error' => 'inertia_multithreaded_share#share_multithreaded_error' 24 | get 'redirect_with_inertia_errors' => 'inertia_test#redirect_with_inertia_errors' 25 | post 'redirect_with_inertia_errors' => 'inertia_test#redirect_with_inertia_errors' 26 | post 'redirect_back_with_inertia_errors' => 'inertia_test#redirect_back_with_inertia_errors' 27 | get 'error_404' => 'inertia_test#error_404' 28 | get 'error_500' => 'inertia_test#error_500' 29 | get 'content_type_test' => 'inertia_test#content_type_test' 30 | get 'lazy_props' => 'inertia_render_test#lazy_props' 31 | get 'non_inertiafied' => 'inertia_test#non_inertiafied' 32 | 33 | get 'instance_props_test' => 'inertia_rails_mimic#instance_props_test' 34 | get 'default_render_test' => 'inertia_rails_mimic#default_render_test' 35 | get 'default_component_test' => 'inertia_rails_mimic#default_component_test' 36 | get 'provided_props_test' => 'inertia_rails_mimic#provided_props_test' 37 | 38 | post 'redirect_to_share_test' => 'inertia_test#redirect_to_share_test' 39 | inertia 'inertia_route' => 'TestComponent' 40 | 41 | get 'merge_shared' => 'inertia_merge_shared#merge_shared' 42 | get 'deep_merge_shared' => 'inertia_merge_shared#deep_merge_shared' 43 | get 'shallow_merge_shared' => 'inertia_merge_shared#shallow_merge_shared' 44 | get 'merge_instance_props' => 'inertia_merge_instance_props#merge_instance_props' 45 | 46 | get 'lamda_shared_props' => 'inertia_lambda_shared_props#lamda_shared_props' 47 | 48 | get 'initialize_session' => 'inertia_session_continuity_test#initialize_session' 49 | post 'submit_form_to_test_csrf' => 'inertia_session_continuity_test#submit_form_to_test_csrf' 50 | delete 'clear_session' => 'inertia_session_continuity_test#clear_session' 51 | 52 | get 'conditional_share_index' => 'inertia_conditional_sharing#index' 53 | get 'conditional_share_show' => 'inertia_conditional_sharing#show' 54 | end 55 | -------------------------------------------------------------------------------- /lib/generators/inertia_rails/install_generator.rb: -------------------------------------------------------------------------------- 1 | module InertiaRails 2 | class InstallGenerator < Rails::Generators::Base 3 | source_root File.expand_path('./install', __dir__) 4 | class_option :front_end, type: :string, default: 'react' 5 | 6 | FRONT_END_INSTALLERS = [ 7 | 'react', 8 | 'vue', 9 | 'svelte', 10 | ] 11 | 12 | def install 13 | exit! unless installable? 14 | 15 | install_base! 16 | 17 | send "install_#{options[:front_end]}!" 18 | 19 | say "You're all set! Run rails s and checkout localhost:3000/inertia-example", :green 20 | end 21 | 22 | protected 23 | 24 | def installable? 25 | unless run("./bin/rails webpacker:verify_install") 26 | say "Sorry, you need to have webpacker installed for inertia_rails default setup.", :red 27 | return false 28 | end 29 | 30 | unless options[:front_end].in? FRONT_END_INSTALLERS 31 | say "Sorry, there is no generator for #{options[:front_end]}!\n\n", :red 32 | say "If you are a #{options[:front_end]} developer, please help us improve inertia_rails by contributing an installer.\n\n" 33 | say "https://github.com/inertiajs/inertia-rails/\n\n" 34 | 35 | return false 36 | end 37 | 38 | true 39 | end 40 | 41 | def install_base! 42 | say "Adding inertia pack tag to application layout", :blue 43 | insert_into_file Rails.root.join("app/views/layouts/application.html.erb").to_s, after: "<%= javascript_pack_tag 'application' %>\n" do 44 | "\t\t<%= javascript_pack_tag 'inertia' %>\n" 45 | end 46 | 47 | say "Installing inertia client packages", :blue 48 | run "yarn add @inertiajs/inertia @inertiajs/progress" 49 | 50 | say "Copying example files", :blue 51 | template "controller.rb", Rails.root.join("app/controllers/inertia_example_controller.rb").to_s 52 | 53 | say "Adding a route for the example inertia controller...", :blue 54 | route "get 'inertia-example', to: 'inertia_example#index'" 55 | end 56 | 57 | def install_react! 58 | say "Creating a React page component...", :blue 59 | run 'yarn add @inertiajs/inertia-react' 60 | template "react/InertiaExample.jsx", Rails.root.join("app/javascript/Pages/InertiaExample.js").to_s 61 | say "Copying inertia.jsx into webpacker's packs folder...", :blue 62 | template "react/inertia.jsx", Rails.root.join("app/javascript/packs/inertia.jsx").to_s 63 | say "done!", :green 64 | end 65 | 66 | def install_vue! 67 | say "Creating a Vue page component...", :blue 68 | run 'yarn add @inertiajs/inertia-vue' 69 | template "vue/InertiaExample.vue", Rails.root.join("app/javascript/Pages/InertiaExample.vue").to_s 70 | say "Copying inertia.js into webpacker's packs folder...", :blue 71 | template "vue/inertia.js", Rails.root.join("app/javascript/packs/inertia.js").to_s 72 | say "done!", :green 73 | end 74 | 75 | def install_svelte! 76 | say "Creating a Svelte page component...", :blue 77 | run 'yarn add @inertiajs/inertia-svelte' 78 | template "svelte/InertiaExample.svelte", Rails.root.join("app/javascript/Pages/InertiaExample.svelte").to_s 79 | say "Copying inertia.js into webpacker's packs folder...", :blue 80 | template "svelte/inertia.js", Rails.root.join("app/javascript/packs/inertia.js").to_s 81 | say "done!", :green 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at TODO: Write your email address. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/inertia_rails/controller.rb: -------------------------------------------------------------------------------- 1 | require_relative "inertia_rails" 2 | require_relative "helper" 3 | 4 | module InertiaRails 5 | module Controller 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | helper ::InertiaRails::Helper 10 | 11 | after_action do 12 | cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery? 13 | end 14 | end 15 | 16 | module ClassMethods 17 | def inertia_share(attrs = {}, &block) 18 | @inertia_share ||= [] 19 | @inertia_share << attrs.freeze unless attrs.empty? 20 | @inertia_share << block if block 21 | end 22 | 23 | def inertia_config(**attrs) 24 | config = InertiaRails::Configuration.new(**attrs) 25 | 26 | if @inertia_config 27 | @inertia_config.merge!(config) 28 | else 29 | @inertia_config = config 30 | end 31 | end 32 | 33 | def use_inertia_instance_props 34 | before_action do 35 | @_inertia_instance_props = true 36 | @_inertia_skip_props = view_assigns.keys + ['_inertia_skip_props'] 37 | end 38 | end 39 | 40 | def _inertia_configuration 41 | @_inertia_configuration ||= begin 42 | config = superclass.try(:_inertia_configuration) || ::InertiaRails.configuration 43 | @inertia_config&.with_defaults(config) || config 44 | end 45 | end 46 | 47 | def _inertia_shared_data 48 | @_inertia_shared_data ||= begin 49 | shared_data = superclass.try(:_inertia_shared_data) 50 | 51 | if @inertia_share && shared_data.present? 52 | shared_data + @inertia_share.freeze 53 | else 54 | @inertia_share || shared_data || [] 55 | end.freeze 56 | end 57 | end 58 | end 59 | 60 | def default_render 61 | if inertia_configuration.default_render 62 | render(inertia: true) 63 | else 64 | super 65 | end 66 | end 67 | 68 | def redirect_to(options = {}, response_options = {}) 69 | capture_inertia_errors(response_options) 70 | super(options, response_options) 71 | end 72 | 73 | def redirect_back(fallback_location:, allow_other_host: true, **options) 74 | capture_inertia_errors(options) 75 | super( 76 | fallback_location: fallback_location, 77 | allow_other_host: allow_other_host, 78 | **options, 79 | ) 80 | end 81 | 82 | private 83 | 84 | def inertia_view_assigns 85 | return {} unless @_inertia_instance_props 86 | view_assigns.except(*@_inertia_skip_props) 87 | end 88 | 89 | def inertia_configuration 90 | self.class._inertia_configuration.bind_controller(self) 91 | end 92 | 93 | def inertia_shared_data 94 | initial_data = session[:inertia_errors].present? ? {errors: session[:inertia_errors]} : {} 95 | 96 | self.class._inertia_shared_data.filter_map { |shared_data| 97 | if shared_data.respond_to?(:call) 98 | instance_exec(&shared_data) 99 | else 100 | shared_data 101 | end 102 | }.reduce(initial_data, &:merge) 103 | end 104 | 105 | def inertia_location(url) 106 | headers['X-Inertia-Location'] = url 107 | head :conflict 108 | end 109 | 110 | def capture_inertia_errors(options) 111 | if (inertia_errors = options.dig(:inertia, :errors)) 112 | session[:inertia_errors] = inertia_errors 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/inertia/sharing_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'using inertia share when rendering views', type: :request do 2 | subject { JSON.parse(response.body)['props'].deep_symbolize_keys } 3 | 4 | context 'using inertia share' do 5 | let(:props) { {name: 'Brandon', sport: 'hockey', position: 'center', number: 29} } 6 | before { get share_path, headers: {'X-Inertia' => true} } 7 | 8 | it { is_expected.to eq props } 9 | end 10 | 11 | context 'inertia share across requests' do 12 | before do 13 | get share_path, headers: {'X-Inertia' => true} 14 | get empty_test_path, headers: {'X-Inertia' => true} 15 | end 16 | 17 | it { is_expected.to eq({}) } 18 | end 19 | 20 | context 'using inertia share in subsequent requests' do 21 | let(:props) { {name: 'Brandon', sport: 'hockey', position: 'center', number: 29} } 22 | 23 | before do 24 | get share_path, headers: {'X-Inertia' => true} 25 | get share_path, headers: {'X-Inertia' => true} 26 | end 27 | 28 | it { is_expected.to eq props } 29 | end 30 | 31 | context 'using inertia share with inheritance' do 32 | let(:props) { {name: 'No Longer Brandon', sport: 'hockey', position: 'center', number: 29} } 33 | 34 | before do 35 | get share_with_inherited_path, headers: {'X-Inertia' => true} 36 | end 37 | 38 | it { is_expected.to eq props } 39 | end 40 | 41 | context 'with errors' do 42 | let(:props) { {name: 'Brandon', sport: 'hockey', position: 'center', number: 29} } 43 | let(:errors) { 'rearview mirror is present' } 44 | before { 45 | allow_any_instance_of(ActionDispatch::Request).to receive(:session) { 46 | spy(ActionDispatch::Request::Session).tap do |spy| 47 | allow(spy).to receive(:[]) 48 | allow(spy).to receive(:[]).with(:inertia_errors).and_return(errors) 49 | end 50 | } 51 | get share_path, headers: {'X-Inertia' => true} 52 | } 53 | 54 | it { is_expected.to eq props.merge({ errors: errors }) } 55 | end 56 | 57 | describe 'deep or shallow merging shared data' do 58 | context 'with default settings (shallow merge)' do 59 | describe 'shallow merging by default' do 60 | let(:props) { { nested: { assists: 200 } } } 61 | before { get merge_shared_path, headers: {'X-Inertia' => true} } 62 | it { is_expected.to eq props } 63 | end 64 | 65 | context 'with deep merge added to the renderer' do 66 | let(:props) { { nested: { goals: 100, assists: 300 } } } 67 | before { get deep_merge_shared_path, headers: {'X-Inertia' => true} } 68 | it { is_expected.to eq props } 69 | end 70 | end 71 | 72 | context 'with deep merge configured as the default' do 73 | before { 74 | InertiaRails.configure { |config| config.deep_merge_shared_data = true } 75 | } 76 | after { 77 | InertiaRails.configure { |config| config.deep_merge_shared_data = false } 78 | } 79 | describe 'deep merging by default' do 80 | let(:props) { { nested: { goals: 100, assists: 200 } } } 81 | before { get merge_shared_path, headers: {'X-Inertia' => true} } 82 | it { is_expected.to eq props } 83 | end 84 | 85 | describe 'overriding deep merge in a specific action' do 86 | let(:props) { { nested: { assists: 200 } } } 87 | before { get shallow_merge_shared_path, headers: {'X-Inertia' => true} } 88 | it { is_expected.to eq props } 89 | end 90 | end 91 | 92 | context 'merging with instance props' do 93 | let(:props) { { nested: { points: 100, rebounds: 10 } } } 94 | before { get merge_instance_props_path, headers: {'X-Inertia' => true} } 95 | it { is_expected.to eq props } 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/inertia_rails/renderer.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'json' 3 | require_relative "inertia_rails" 4 | 5 | module InertiaRails 6 | class Renderer 7 | attr_reader( 8 | :component, 9 | :configuration, 10 | :controller, 11 | :props, 12 | :view_data, 13 | ) 14 | 15 | def initialize(component, controller, request, response, render_method, props: nil, view_data: nil, deep_merge: nil) 16 | @component = component.is_a?(TrueClass) ? "#{controller.controller_path}/#{controller.action_name}" : component 17 | @controller = controller 18 | @configuration = controller.__send__(:inertia_configuration) 19 | @request = request 20 | @response = response 21 | @render_method = render_method 22 | @props = props || controller.__send__(:inertia_view_assigns) 23 | @view_data = view_data || {} 24 | @deep_merge = !deep_merge.nil? ? deep_merge : configuration.deep_merge_shared_data 25 | end 26 | 27 | def render 28 | if @response.headers["Vary"].blank? 29 | @response.headers["Vary"] = 'X-Inertia' 30 | else 31 | @response.headers["Vary"] = "#{@response.headers["Vary"]}, X-Inertia" 32 | end 33 | if @request.headers['X-Inertia'] 34 | @response.set_header('X-Inertia', 'true') 35 | @render_method.call json: page, status: @response.status, content_type: Mime[:json] 36 | else 37 | return render_ssr if configuration.ssr_enabled rescue nil 38 | @render_method.call template: 'inertia', layout: layout, locals: view_data.merge(page: page) 39 | end 40 | end 41 | 42 | private 43 | 44 | def render_ssr 45 | uri = URI("#{configuration.ssr_url}/render") 46 | res = JSON.parse(Net::HTTP.post(uri, page.to_json, 'Content-Type' => 'application/json').body) 47 | 48 | controller.instance_variable_set("@_inertia_ssr_head", res['head'].join.html_safe) 49 | @render_method.call html: res['body'].html_safe, layout: layout, locals: view_data.merge(page: page) 50 | end 51 | 52 | def layout 53 | layout = configuration.layout 54 | layout.nil? ? true : layout 55 | end 56 | 57 | def shared_data 58 | controller.__send__(:inertia_shared_data) 59 | end 60 | 61 | # Cast props to symbol keyed hash before merging so that we have a consistent data structure and 62 | # avoid duplicate keys after merging. 63 | # 64 | # Functionally, this permits using either string or symbol keys in the controller. Since the results 65 | # is cast to json, we should treat string/symbol keys as identical. 66 | def merge_props(shared_data, props) 67 | shared_data.deep_symbolize_keys.send(@deep_merge ? :deep_merge : :merge, props.deep_symbolize_keys) 68 | end 69 | 70 | def computed_props 71 | _props = merge_props(shared_data, props).select do |key, prop| 72 | if rendering_partial_component? 73 | key.in? partial_keys 74 | else 75 | !prop.is_a?(InertiaRails::Lazy) 76 | end 77 | end 78 | 79 | deep_transform_values( 80 | _props, 81 | lambda do |prop| 82 | prop.respond_to?(:call) ? controller.instance_exec(&prop) : prop 83 | end 84 | ) 85 | end 86 | 87 | def page 88 | { 89 | component: component, 90 | props: computed_props, 91 | url: @request.original_fullpath, 92 | version: configuration.version, 93 | } 94 | end 95 | 96 | def deep_transform_values(hash, proc) 97 | return proc.call(hash) unless hash.is_a? Hash 98 | 99 | hash.transform_values {|value| deep_transform_values(value, proc)} 100 | end 101 | 102 | def partial_keys 103 | (@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact.map(&:to_sym) 104 | end 105 | 106 | def rendering_partial_component? 107 | @request.inertia_partial? && @request.headers['X-Inertia-Partial-Component'] == component 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/inertia_rails/rspec.rb: -------------------------------------------------------------------------------- 1 | require "rspec/core" 2 | require "rspec/matchers" 3 | 4 | module InertiaRails 5 | module RSpec 6 | class InertiaRenderWrapper 7 | attr_reader :view_data, :props, :component 8 | 9 | def initialize 10 | @view_data = nil 11 | @props = nil 12 | @component = nil 13 | end 14 | 15 | def call(params) 16 | set_values(params) 17 | @render_method&.call(params) 18 | end 19 | 20 | def wrap_render(render_method) 21 | @render_method = render_method 22 | self 23 | end 24 | 25 | protected 26 | 27 | def set_values(params) 28 | if params[:locals].present? 29 | @view_data = params[:locals].except(:page) 30 | @props = params[:locals][:page][:props] 31 | @component = params[:locals][:page][:component] 32 | else 33 | # Sequential Inertia request 34 | @view_data = {} 35 | @props = params[:json][:props] 36 | @component = params[:json][:component] 37 | end 38 | end 39 | end 40 | 41 | module Helpers 42 | def inertia 43 | raise 'Inertia test helpers aren\'t set up! Make sure you add inertia: true to describe blocks using inertia tests.' unless inertia_tests_setup? 44 | 45 | if @_inertia_render_wrapper.nil? && !::RSpec.configuration.inertia[:skip_missing_renderer_warnings] 46 | warn 'WARNING: the test never created an Inertia renderer. Maybe the code wasn\'t able to reach a `render inertia:` call? If this was intended, or you don\'t want to see this message, set ::RSpec.configuration.inertia[:skip_missing_renderer_warnings] = true' 47 | end 48 | @_inertia_render_wrapper 49 | end 50 | 51 | def expect_inertia 52 | expect(inertia) 53 | end 54 | 55 | def inertia_wrap_render(render) 56 | @_inertia_render_wrapper = InertiaRenderWrapper.new.wrap_render(render) 57 | end 58 | 59 | protected 60 | 61 | def inertia_tests_setup? 62 | ::RSpec.current_example.metadata.fetch(:inertia, false) 63 | end 64 | end 65 | end 66 | end 67 | 68 | RSpec.configure do |config| 69 | config.include ::InertiaRails::RSpec::Helpers 70 | config.add_setting :inertia, default: { 71 | skip_missing_renderer_warnings: false 72 | } 73 | 74 | config.before(:each, inertia: true) do 75 | new_renderer = InertiaRails::Renderer.method(:new) 76 | allow(InertiaRails::Renderer).to receive(:new) do |component, controller, request, response, render, named_args| 77 | new_renderer.call(component, controller, request, response, inertia_wrap_render(render), **named_args) 78 | end 79 | end 80 | end 81 | 82 | RSpec::Matchers.define :have_exact_props do |expected_props| 83 | match do |inertia| 84 | # Computed props have symbolized keys. 85 | expect(inertia.props).to eq expected_props.deep_symbolize_keys 86 | end 87 | 88 | failure_message do |inertia| 89 | "expected inertia props to receive #{expected_props}, instead received #{inertia.props || 'nothing'}" 90 | end 91 | end 92 | 93 | RSpec::Matchers.define :include_props do |expected_props| 94 | match do |inertia| 95 | # Computed props have symbolized keys. 96 | expect(inertia.props).to include expected_props.deep_symbolize_keys 97 | end 98 | 99 | failure_message do |inertia| 100 | "expected inertia props to include #{expected_props}, instead received #{inertia.props || 'nothing'}" 101 | end 102 | end 103 | 104 | RSpec::Matchers.define :render_component do |expected_component| 105 | match do |inertia| 106 | expect(inertia.component).to eq expected_component 107 | end 108 | 109 | failure_message do |inertia| 110 | "expected rendered inertia component to be #{expected_component}, instead received #{inertia.component || 'nothing'}" 111 | end 112 | end 113 | 114 | RSpec::Matchers.define :have_exact_view_data do |expected_view_data| 115 | match do |inertia| 116 | expect(inertia.view_data).to eq expected_view_data 117 | end 118 | 119 | failure_message do |inertia| 120 | "expected inertia view data to receive #{expected_view_data}, instead received #{inertia.view_data || 'nothing'}" 121 | end 122 | end 123 | 124 | RSpec::Matchers.define :include_view_data do |expected_view_data| 125 | match do |inertia| 126 | expect(inertia.view_data).to include expected_view_data 127 | end 128 | 129 | failure_message do |inertia| 130 | "expected inertia view data to include #{expected_view_data}, instead received #{inertia.view_data || 'nothing'}" 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/inertia/rspec_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/inertia_rails/rspec' 2 | 3 | class FakeStdErr 4 | attr_accessor :messages 5 | 6 | def initialize 7 | @messages = [] 8 | end 9 | 10 | def write(msg) 11 | @messages << msg 12 | end 13 | 14 | # Rails 5.0 + Ruby 2.6 require puts to be a public method 15 | def puts(thing) 16 | end 17 | end 18 | 19 | RSpec.describe InertiaRails::RSpec, type: :request do 20 | describe 'correctly set up inertia tests with inertia: true', inertia: true do 21 | context 'with props' do 22 | before { get props_path } 23 | 24 | it 'has props' do 25 | expect_inertia.to have_exact_props({name: 'Brandon', sport: 'hockey'}) 26 | end 27 | 28 | it 'includes props' do 29 | expect_inertia.to include_props({sport: 'hockey'}) 30 | end 31 | 32 | it 'can retrieve props' do 33 | expect(inertia.props[:name]).to eq 'Brandon' 34 | end 35 | end 36 | 37 | context 'with props during sequential request' do 38 | before { get props_path, headers: {'X-Inertia': true} } 39 | 40 | it 'has props' do 41 | expect_inertia.to have_exact_props({name: 'Brandon', sport: 'hockey'}) 42 | end 43 | 44 | it 'includes props' do 45 | expect_inertia.to include_props({sport: 'hockey'}) 46 | end 47 | 48 | it 'can retrieve props' do 49 | expect(inertia.props[:name]).to eq 'Brandon' 50 | end 51 | end 52 | 53 | context 'with view data' do 54 | before { get view_data_path } 55 | 56 | it 'has view data' do 57 | expect_inertia.to have_exact_view_data({name: 'Brian', sport: 'basketball'}) 58 | end 59 | 60 | it 'includes view data' do 61 | expect_inertia.to include_view_data({sport: 'basketball'}) 62 | end 63 | 64 | it 'can retrieve view data' do 65 | expect(inertia.view_data[:name]).to eq 'Brian' 66 | end 67 | end 68 | 69 | context 'with component name' do 70 | before { get component_path } 71 | 72 | it 'has the component name' do 73 | expect_inertia.to render_component 'TestComponent' 74 | end 75 | 76 | it 'can retrieve the component name' do 77 | expect(inertia.component).to eq 'TestComponent' 78 | end 79 | end 80 | end 81 | 82 | describe 'inertia tests missing the inertia: true flag' do 83 | before { get component_path } 84 | 85 | it 'warns you to add inertia: true' do 86 | expect { expect_inertia }.to raise_error(/inertia: true/) 87 | end 88 | end 89 | 90 | describe 'expecting inertia on a non inertia route', inertia: true do 91 | before { get non_inertiafied_path } 92 | 93 | it 'does not complain about test helpers' do 94 | expect { expect_inertia }.not_to raise_error 95 | end 96 | 97 | # h/t for this technique: 98 | # https://blog.arkency.com/testing-deprecations-warnings-with-rspec/ 99 | it 'warns that the renderer was never created' do 100 | begin 101 | original_stderr = $stderr 102 | fake_std_err = FakeStdErr.new 103 | $stderr = fake_std_err 104 | expect_inertia 105 | warn_message = 'WARNING: the test never created an Inertia renderer. Maybe the code wasn\'t able to reach a `render inertia:` call? If this was intended, or you don\'t want to see this message, set ::RSpec.configuration.inertia[:skip_missing_renderer_warnings] = true' 106 | expect(fake_std_err.messages[0].chomp).to(eq(warn_message)) 107 | ensure 108 | $std_err = original_stderr 109 | end 110 | end 111 | 112 | context 'with the :skip_missing_renderer_warnings setting set to true' do 113 | before { 114 | @original = ::RSpec.configuration.inertia[:skip_missing_renderer_warnings] 115 | ::RSpec.configuration.inertia[:skip_missing_renderer_warnings] = true 116 | } 117 | after { 118 | ::RSpec.configuration.inertia[:skip_missing_renderer_warnings] = @original 119 | } 120 | it 'skips the warning' do 121 | begin 122 | original_stderr = $stderr 123 | fake_std_err = FakeStdErr.new 124 | $stderr = fake_std_err 125 | expect_inertia 126 | expect(fake_std_err.messages).to be_empty 127 | ensure 128 | $std_err = original_stderr 129 | end 130 | end 131 | end 132 | end 133 | 134 | describe '.have_exact_props' do 135 | context 'when shared props are wrapped in a callable', inertia: true do 136 | it 'compares props with either string or symbol keys' do 137 | get lamda_shared_props_path 138 | 139 | expect_inertia.to have_exact_props({ 140 | someProperty: { 141 | property_a: "some value", 142 | 'property_b' => "this value", 143 | }, 144 | property_c: "some other value" 145 | }) 146 | end 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 18 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 19 | # config.require_master_key = true 20 | 21 | # Disable serving static files from the `/public` folder by default since 22 | # Apache or NGINX already handles this. 23 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 24 | 25 | # Compress CSS using a preprocessor. 26 | # config.assets.css_compressor = :sass 27 | 28 | # Do not fallback to assets pipeline if a precompiled asset is missed. 29 | config.assets.compile = false 30 | 31 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 32 | # config.action_controller.asset_host = 'http://assets.example.com' 33 | 34 | # Specifies the header that your server uses for sending files. 35 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 36 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 37 | 38 | # Mount Action Cable outside main process or domain. 39 | # config.action_cable.mount_path = nil 40 | # config.action_cable.url = 'wss://example.com/cable' 41 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 42 | 43 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 44 | # config.force_ssl = true 45 | 46 | # Use the lowest log level to ensure availability of diagnostic information 47 | # when problems arise. 48 | config.log_level = :debug 49 | 50 | # Prepend all log lines with the following tags. 51 | config.log_tags = [ :request_id ] 52 | 53 | # Use a different cache store in production. 54 | # config.cache_store = :mem_cache_store 55 | 56 | # Use a real queuing backend for Active Job (and separate queues per environment). 57 | # config.active_job.queue_adapter = :resque 58 | # config.active_job.queue_name_prefix = "dummy_production" 59 | 60 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 61 | # the I18n.default_locale when a translation cannot be found). 62 | config.i18n.fallbacks = true 63 | 64 | # Send deprecation notices to registered listeners. 65 | config.active_support.deprecation = :notify 66 | 67 | # Use default logging formatter so that PID and timestamp are not suppressed. 68 | config.log_formatter = ::Logger::Formatter.new 69 | 70 | # Use a different logger for distributed setups. 71 | # require 'syslog/logger' 72 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 73 | 74 | if ENV["RAILS_LOG_TO_STDOUT"].present? 75 | logger = ActiveSupport::Logger.new(STDOUT) 76 | logger.formatter = config.log_formatter 77 | config.logger = ActiveSupport::TaggedLogging.new(logger) 78 | end 79 | 80 | # Do not dump schema after migrations. 81 | config.active_record.dump_schema_after_migration = false 82 | 83 | # Inserts middleware to perform automatic connection switching. 84 | # The `database_selector` hash is used to pass options to the DatabaseSelector 85 | # middleware. The `delay` is used to determine how long to wait after a write 86 | # to send a subsequent read to the primary. 87 | # 88 | # The `database_resolver` class is used by the middleware to determine which 89 | # database is appropriate to use based on the time delay. 90 | # 91 | # The `database_resolver_context` class is used by the middleware to set 92 | # timestamps for the last write to the primary. The resolver uses the context 93 | # class timestamps to determine how long to wait before reading from the 94 | # replica. 95 | # 96 | # By default Rails will store a last write timestamp in the session. The 97 | # DatabaseSelector middleware is designed as such you can define your own 98 | # strategy for connection switching and pass that into the middleware through 99 | # these configuration options. 100 | # config.active_record.database_selector = { delay: 2.seconds } 101 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 102 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 103 | end 104 | -------------------------------------------------------------------------------- /spec/inertia/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'Inertia configuration', type: :request do 2 | after { reset_config! } 3 | 4 | describe "InertiaRails::Configuration" do 5 | it "does not allow to modify options after frozen" do 6 | config = InertiaRails::Configuration.default 7 | config.ssr_enabled = true 8 | expect(config.ssr_enabled).to eq true 9 | 10 | config.freeze 11 | expect { config.ssr_enabled = false }.to raise_error(FrozenError) 12 | expect { config.merge!(InertiaRails::Configuration.default) }.to raise_error(FrozenError) 13 | 14 | expect { 15 | merged_config = config.merge(InertiaRails::Configuration.default) 16 | expect(merged_config.ssr_enabled).to eq false 17 | }.not_to raise_error 18 | end 19 | end 20 | 21 | describe 'inertia_config' do 22 | it 'overrides the global values' do 23 | get configuration_path 24 | 25 | expect(response.parsed_body.symbolize_keys).to eq( 26 | deep_merge_shared_data: true, 27 | default_render: false, 28 | layout: "test", 29 | ssr_enabled: true, 30 | ssr_url: "http://localhost:7777", 31 | version: "2.0", 32 | ) 33 | end 34 | end 35 | 36 | describe '.version' do 37 | subject { JSON.parse(response.body)['version'] } 38 | 39 | context 'base case' do 40 | before { get empty_test_path, headers: {'X-Inertia' => true} } 41 | 42 | it { is_expected.to be_nil } 43 | end 44 | 45 | context 'version is a string' do 46 | before do 47 | InertiaRails.configure {|c| c.version = '1.0'} 48 | get empty_test_path, headers: {'X-Inertia' => true, 'HTTP_X_INERTIA_VERSION' => '1.0'} 49 | end 50 | 51 | it { is_expected.to eq '1.0' } 52 | end 53 | 54 | context 'version is a callable' do 55 | before do 56 | InertiaRails.configure {|c| c.version = -> {'1.0'}} 57 | get empty_test_path, headers: {'X-Inertia' => true, 'X-Inertia-Version' => '1.0'} 58 | end 59 | 60 | it { is_expected.to eq '1.0' } 61 | end 62 | 63 | context 'string vs float mismatches' do 64 | before do 65 | InertiaRails.configure {|c| c.version = 1.0} 66 | get empty_test_path, headers: {'X-Inertia' => true, 'X-Inertia-Version' => '1.0'} 67 | end 68 | 69 | it { is_expected.to eq 1.0 } 70 | end 71 | 72 | context 'with a new version' do 73 | before do 74 | InertiaRails.configure { |c| c.version = '1.0' } 75 | end 76 | 77 | context 'request in same thread' do 78 | before do 79 | get empty_test_path, headers: {'X-Inertia' => true, 'X-Inertia-Version' => '1.0'} 80 | end 81 | 82 | it { is_expected.to eq '1.0' } 83 | end 84 | 85 | context 'request in other thread' do 86 | before do 87 | Thread.new do 88 | get empty_test_path, headers: {'X-Inertia' => true, 'X-Inertia-Version' => '1.0'} 89 | end.join 90 | end 91 | 92 | it { is_expected.to eq '1.0' } 93 | end 94 | end 95 | end 96 | 97 | describe '.layout' do 98 | subject { response.body } 99 | 100 | context 'base case' do 101 | before { get empty_test_path } 102 | 103 | it { is_expected.to render_template 'inertia' } 104 | it { is_expected.to render_template 'application' } 105 | end 106 | 107 | context 'with a new layout' do 108 | before do 109 | InertiaRails.configure {|c| c.layout = 'testing' } 110 | end 111 | 112 | context 'request in same thread' do 113 | before do 114 | get empty_test_path 115 | end 116 | 117 | it { is_expected.to render_template 'inertia' } 118 | it { is_expected.to render_template 'testing' } 119 | it { is_expected.not_to render_template 'application' } 120 | end 121 | 122 | context 'request in other thread' do 123 | before do 124 | Thread.new do 125 | get empty_test_path 126 | end.join 127 | end 128 | 129 | it { is_expected.to render_template 'inertia' } 130 | it { is_expected.to render_template 'testing' } 131 | it { is_expected.not_to render_template 'application' } 132 | end 133 | 134 | context 'opting out of a different layout for Inertia' do 135 | before do 136 | InertiaRails.configure {|c| c.layout = true } 137 | end 138 | 139 | it 'uses default layout for controller' do 140 | get empty_test_path 141 | is_expected.to render_template 'inertia' 142 | is_expected.to render_template 'application' 143 | is_expected.not_to render_template 'testing' 144 | end 145 | 146 | it 'applies conditional layouts as needed' do 147 | get with_different_layout_path 148 | is_expected.to render_template 'inertia' 149 | is_expected.to render_template 'conditional' 150 | is_expected.not_to render_template 'application' 151 | is_expected.not_to render_template 'testing' 152 | end 153 | end 154 | end 155 | end 156 | end 157 | 158 | def reset_config! 159 | InertiaRails.configure do |config| 160 | config.version = nil 161 | config.layout = 'application' 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/inertia/rendering_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'rendering inertia views', type: :request do 2 | subject { response.body } 3 | 4 | let(:controller) { ApplicationController.new.tap { |controller| controller.set_request!(request) } } 5 | 6 | context 'first load' do 7 | let(:page) { InertiaRails::Renderer.new('TestComponent', controller, request, response, '').send(:page) } 8 | 9 | context 'with props' do 10 | let(:page) { InertiaRails::Renderer.new('TestComponent', controller, request, response, '', props: {name: 'Brandon', sport: 'hockey'}).send(:page) } 11 | before { get props_path } 12 | 13 | it { is_expected.to include inertia_div(page) } 14 | end 15 | 16 | context 'with view data' do 17 | before { get view_data_path } 18 | 19 | it { is_expected.to include inertia_div(page) } 20 | it { is_expected.to include({name: 'Brian', sport: 'basketball'}.to_json) } 21 | end 22 | 23 | context 'with no data' do 24 | before { get component_path } 25 | 26 | it { is_expected.to include inertia_div(page) } 27 | end 28 | 29 | it 'has the proper status code' do 30 | get component_path 31 | expect(response.status).to eq 200 32 | end 33 | 34 | describe 'headers' do 35 | context 'when no other Vary header is present' do 36 | it 'has the proper headers' do 37 | get component_path 38 | 39 | expect(response.headers['X-Inertia']).to be_nil 40 | expect(response.headers['Vary']).to eq 'X-Inertia' 41 | expect(response.headers['Content-Type']).to eq 'text/html; charset=utf-8' 42 | end 43 | end 44 | 45 | context 'when another Vary header is present' do 46 | it 'has the proper headers' do 47 | get vary_header_path 48 | 49 | expect(response.headers['X-Inertia']).to be_nil 50 | expect(response.headers['Vary']).to eq 'Accept-Language, X-Inertia' 51 | expect(response.headers['Content-Type']).to eq 'text/html; charset=utf-8' 52 | end 53 | end 54 | end 55 | 56 | context 'via an inertia route' do 57 | before { get inertia_route_path } 58 | 59 | it { is_expected.to include inertia_div(page) } 60 | end 61 | end 62 | 63 | context 'subsequent requests' do 64 | let(:page) { InertiaRails::Renderer.new('TestComponent', controller, request, response, '', props: {name: 'Brandon', sport: 'hockey'}).send(:page) } 65 | let(:headers) { {'X-Inertia' => true} } 66 | 67 | before { get props_path, headers: headers } 68 | 69 | it { is_expected.to eq page.to_json } 70 | 71 | 72 | it 'has the proper headers' do 73 | expect(response.headers['X-Inertia']).to eq 'true' 74 | expect(response.headers['Vary']).to eq 'X-Inertia' 75 | expect(response.headers['Content-Type']).to eq 'application/json; charset=utf-8' 76 | end 77 | 78 | it 'has the proper body' do 79 | expect(JSON.parse(response.body)).to include('url' => '/props') 80 | end 81 | 82 | it 'has the proper status code' do 83 | expect(response.status).to eq 200 84 | end 85 | end 86 | 87 | context 'partial rendering' do 88 | let (:page) { 89 | InertiaRails::Renderer.new('TestComponent', controller, request, response, '', props: { sport: 'hockey' }).send(:page) 90 | } 91 | let(:headers) {{ 92 | 'X-Inertia' => true, 93 | 'X-Inertia-Partial-Data' => 'sport', 94 | 'X-Inertia-Partial-Component' => 'TestComponent', 95 | }} 96 | 97 | context 'with the correct partial component header' do 98 | before { get props_path, headers: headers } 99 | 100 | it { is_expected.to eq page.to_json } 101 | it { is_expected.to include('hockey') } 102 | end 103 | 104 | context 'with a non matching partial component header' do 105 | before { 106 | headers['X-Inertia-Partial-Component'] = 'NotTheTestComponent' 107 | get props_path, headers: headers 108 | } 109 | 110 | it { is_expected.not_to eq page.to_json } 111 | it 'includes all of the props' do 112 | is_expected.to include('Brandon') 113 | end 114 | end 115 | end 116 | 117 | context 'lazy prop rendering' do 118 | context 'on first load' do 119 | let (:page) { 120 | InertiaRails::Renderer.new('TestComponent', controller, request, response, '', props: { name: 'Brian'}).send(:page) 121 | } 122 | before { get lazy_props_path } 123 | 124 | it { is_expected.to include inertia_div(page) } 125 | end 126 | 127 | context 'with a partial reload' do 128 | let (:page) { 129 | InertiaRails::Renderer.new('TestComponent', controller, request, response, '', props: { sport: 'basketball', level: 'worse than he believes', grit: 'intense'}).send(:page) 130 | } 131 | let(:headers) {{ 132 | 'X-Inertia' => true, 133 | 'X-Inertia-Partial-Data' => 'sport,level', 134 | 'X-Inertia-Partial-Component' => 'TestComponent', 135 | }} 136 | 137 | before { get lazy_props_path, headers: headers } 138 | 139 | it { is_expected.to eq page.to_json } 140 | it { is_expected.to include('basketball') } 141 | it { is_expected.to include('worse') } 142 | it { is_expected.not_to include('intense') } 143 | end 144 | end 145 | end 146 | 147 | def inertia_div(page) 148 | "
" 149 | end 150 | -------------------------------------------------------------------------------- /spec/inertia/request_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'Inertia::Request', type: :request do 2 | describe 'it tests whether a call is an inertia call' do 3 | subject { response.status } 4 | before { get inertia_request_test_path, headers: headers } 5 | 6 | context 'it is an inertia call' do 7 | let(:headers) { {'X-Inertia' => true} } 8 | 9 | it { is_expected.to eq 202 } 10 | end 11 | 12 | context 'it is not an inertia call' do 13 | let(:headers) { Hash.new } 14 | 15 | it { is_expected.to eq 200 } 16 | end 17 | end 18 | 19 | describe 'it tests whether a call is a partial inertia call' do 20 | subject { response.status } 21 | before { get inertia_partial_request_test_path, headers: headers } 22 | 23 | context 'it is a partial inertia call' do 24 | let(:headers) { { 'X-Inertia' => true, 'X-Inertia-Partial-Data' => 'foo,bar,baz' } } 25 | 26 | it { is_expected.to eq 202 } 27 | end 28 | 29 | context 'it is not a partial inertia call' do 30 | let(:headers) { { 'X-Inertia' => true } } 31 | 32 | it { is_expected.to eq 200 } 33 | end 34 | end 35 | 36 | describe 'it tests error 404' do 37 | subject { response.status } 38 | before { get '/error_404', headers: headers } 39 | 40 | context 'it is a inertia call' do 41 | let(:headers) { { 'X-Inertia' => true } } 42 | 43 | it { is_expected.to eq 404 } 44 | end 45 | 46 | context 'it is not a inertia call' do 47 | let(:headers) { {} } 48 | 49 | it { is_expected.to eq 404 } 50 | end 51 | end 52 | 53 | describe 'it tests error 500' do 54 | subject { response.status } 55 | before { get '/error_500', headers: headers } 56 | 57 | context 'it is a inertia call' do 58 | let(:headers) { { 'X-Inertia' => true } } 59 | 60 | it { is_expected.to eq 500 } 61 | end 62 | 63 | context 'it is not a inertia call' do 64 | let(:headers) { {} } 65 | 66 | it { is_expected.to eq 500 } 67 | end 68 | end 69 | 70 | describe 'it tests media_type of the response' do 71 | subject { response.media_type } 72 | before { get content_type_test_path, headers: headers } 73 | 74 | context 'it is an inertia call' do 75 | let(:headers) { {'X-Inertia' => true} } 76 | 77 | it { is_expected.to eq 'application/json' } 78 | end 79 | 80 | context 'it is not an inertia call' do 81 | let(:headers) { Hash.new } 82 | 83 | it { is_expected.to eq 'text/html' } 84 | end 85 | 86 | context 'it is an XML request' do 87 | let(:headers) { { accept: 'application/xml' } } 88 | 89 | it { is_expected.to eq 'application/xml' } 90 | end 91 | end 92 | 93 | describe 'it tests redirecting with responders gem' do 94 | subject { response.status } 95 | before { post redirect_with_responders_path } 96 | 97 | it { is_expected.to eq 302 } 98 | end 99 | 100 | describe 'CSRF' do 101 | describe 'it sets the XSRF-TOKEN in the cookies' do 102 | subject { response.cookies } 103 | before do 104 | with_forgery_protection do 105 | get inertia_request_test_path, headers: headers 106 | end 107 | end 108 | 109 | context 'it is not an inertia call' do 110 | let(:headers) { Hash.new } 111 | it { is_expected.to include('XSRF-TOKEN') } 112 | end 113 | 114 | context 'it is an inertia call' do 115 | let(:headers){ { 'X-Inertia' => true } } 116 | it { is_expected.to include('XSRF-TOKEN') } 117 | end 118 | end 119 | 120 | describe 'copying an X-XSRF-Token header (like Axios sends by default) into the X-CSRF-Token header (that Rails looks for by default)' do 121 | subject { request.headers['X-CSRF-Token'] } 122 | before { get inertia_request_test_path, headers: headers } 123 | 124 | context 'it is an inertia call' do 125 | let(:headers) {{ 'X-Inertia' => true, 'X-XSRF-Token' => 'foo' }} 126 | it { is_expected.to eq 'foo' } 127 | end 128 | 129 | context 'it is not an inertia call' do 130 | let(:headers) { { 'X-XSRF-Token' => 'foo' } } 131 | it { is_expected.to be_nil } 132 | end 133 | end 134 | 135 | it 'sets the XSRF-TOKEN cookie after the session is cleared during an inertia call' do 136 | with_forgery_protection do 137 | get initialize_session_path 138 | expect(response).to have_http_status(:ok) 139 | initial_xsrf_token_cookie = response.cookies['XSRF-TOKEN'] 140 | 141 | post submit_form_to_test_csrf_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => initial_xsrf_token_cookie } 142 | expect(response).to have_http_status(:ok) 143 | 144 | delete clear_session_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => initial_xsrf_token_cookie } 145 | expect(response).to have_http_status(:see_other) 146 | expect(response.headers['Location']).to eq('http://www.example.com/initialize_session') 147 | 148 | post_logout_xsrf_token_cookie = response.cookies['XSRF-TOKEN'] 149 | expect(post_logout_xsrf_token_cookie).not_to be_nil 150 | expect(post_logout_xsrf_token_cookie).not_to eq(initial_xsrf_token_cookie) 151 | 152 | post submit_form_to_test_csrf_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => post_logout_xsrf_token_cookie } 153 | expect(response).to have_http_status(:ok) 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [3.2.0] - 2024-06-19 8 | 9 | * Refactor the internals of shared Inertia data to use controller instance variables instead of module level variables that run a higher risk of being leaked between requests. Big thanks to @ledermann for the initial work many years ago and to @PedroAugustoRamalhoDuarte for finishing it up! 10 | * Change the Inertia response to set the `Vary` header to `X-Inertia` instead of `Accept`, Thanks @osbre! 11 | * Always set the `XSRF-TOKEN` in an `after_action` request instead of only on non-Inertia requests. This fixes a bug where logging out (and resetting the session) via Inertia would create a CSRF token mismatch on a subsequent Inertia request (until you manually hard refreshed the page). Thanks @jordanhiltunen! 12 | 13 | ## [3.1.4] - 2024-04-28 14 | 15 | * Reset Inertia shared data after each RSpec example where `inertia: true` is used. Thanks @coreyaus! 16 | * Update Github Actions workflows to use currently supported Ruby/Rails versions. Thanks @PedroAugustoRamalhoDuarte! 17 | 18 | ## [3.1.3] - 2023-11-03 19 | 20 | * Depend on railties instead of rails so that applications which only use pieces of Rails can avoid a full Rails installation. Thanks @BenMorganMY! 21 | 22 | ## [3.1.2] - 2023-09-26 23 | 24 | * Fix `have_exact_props` RSpec matcher in the situation where shared props are defined in a lambda that outputs a hash with symbolized keys 25 | 26 | ## [3.1.1] - 2023-08-21 27 | 28 | * Fix broken partial reloads caused by comparing a list of symbolized keys with string keys from HashWithIndifferentAccess 29 | 30 | ## [3.1.0] - 2023-08-21 31 | 32 | ### Features 33 | 34 | * CSRF protection works without additional configuration now. 35 | * Optional deep merging of shared props. 36 | 37 | ### Fixes 38 | 39 | * Document Inertia headers. @buhrmi 40 | * Documentation typo fix. @lujanfernaud 41 | * Changelog URI fix. @PedroAugustoRamalhoDuarte 42 | 43 | ## [3.0.0] - 2022-09-22 44 | 45 | * Allow rails layout to set inertia layout. Thanks @ElMassimo! 46 | * Add the ability to set inertia props and components via rails conventions (see readme) 47 | 48 | ## [2.0.1] - 2022-07-12 49 | 50 | * Fix for a middleware issue where global state could be polluted if an exception occurs in a request. Thanks @ElMassimo! 51 | 52 | ## [2.0.0] - 2022-06-20 53 | 54 | * Fix an issue with Rails 7.0. Thanks @0xDing and @aviemet! 55 | * Drop support for Rails 5.0 (and mentally, though not literally drop support for Rails < 6) 56 | 57 | ## [1.12.1] - 2022-05-09 58 | 59 | * Allow inertia to take over after initial pageload when using ssr. Thanks @99monkey! 60 | 61 | ## [1.12.0] - 2022-05-04 62 | 63 | * SSR! 64 | 65 | ## [1.11.1] - 2021-06-27 66 | 67 | * Fixed thread safety in the middleware. Thanks @caifara! 68 | 69 | ## [1.11.0] - 2021-03-23 70 | 71 | * Fixed the install generator. `installable?` was always returning false, preventing it from actually running. 72 | * Added an install generator for Vue. 73 | 74 | ## [1.10.0] - 2021-03-22 75 | 76 | * Added install generator to quickly add Inertia to existing rails apps via `rails inertia_rails:install:react` 77 | 78 | ## [1.9.2] - 2021-02-23 79 | 80 | * Improve method for detecting whether a user used the RSpec helpers without adding `inertia: true` to the spec 81 | * Emit a warning when expecting an Inertia response in RSpec and never reaching a `render inertia:` call 82 | 83 | ## [1.9.1] - 2021-02-10 84 | 85 | * Define `redirect_to` and `redirect_back` as public methods for compatibility with other code using them 86 | 87 | ## [1.9.0] - 2021-01-17 88 | 89 | * Added the same inertia awareness that redirect_to has to redirect_back 90 | 91 | ## [1.8.0] - 2020-12-08 92 | 93 | * Add `inertia` route helper feature 94 | 95 | ## [1.7.1] - 2020-11-24 96 | 97 | * Fix the definition for InertiaRails::Lazy to avoid an uninitialized constant error when booting an application. 98 | 99 | ## [1.7.0] - 2020-11-24 100 | 101 | * Add support for "lazy" props while rendering. These are props that never compute on the initial page load. The only render during a partial update that calls for them explicitly. 102 | 103 | ## [1.6.0] - 2020-11-20 104 | 105 | * Built in error sharing across redirects! adding `{ inertia: { errors: 'errors go here' } }` as an option in `redirect_to` will automatically feed an `errors` prop to whatever is rendered after the redirect. 106 | * Set content type to json for Inertia responses 107 | * Return the original response status with Inertia responses 108 | 109 | ## [1.5.0] - 2020-10-07 110 | 111 | * Test against multiple Rails versions in Github Actions 112 | * Add the `inertia_location` controller method that forces a full page refresh 113 | 114 | ## [1.4.1] - 2020-08-06 115 | 116 | * Fixed a bug involving threadsafe versions and layouts 117 | 118 | ## [1.4.0] - 2020-07-09 119 | 120 | * Fixed Ruby 2.7 deprecation warnings 121 | * Added `inertia_partial?` method 122 | * Fixed homepage in the gemspec 123 | * Make the InertiaRails module data threadsafe 124 | 125 | ## [1.3.1] - 2020-02-20 126 | 127 | * Fix a typo in the README (inertia only has 1 t!) 128 | 129 | ## [1.3.0] - 2020-01-28 130 | 131 | ### Added 132 | 133 | * Added request.inertia? method 134 | 135 | ## [1.2.2] - 2020-01-21 136 | 137 | ### Fixed 138 | 139 | * Added patches to allow Rails errors to show properly in the inertia modal 140 | * Fixed a middleware issue caused by a breaking change in Rack v2.1.* 141 | 142 | ## [1.2.1] - 2019-12-6 143 | 144 | ### Fixed 145 | 146 | * Change page url to use path instead of url 147 | * Moved Inertia Share logic to a before_action to ensure it runs on every request 148 | 149 | ## [1.2.0] - 2019-11-1 150 | 151 | ### Added 152 | 153 | * Added rspec helpers 154 | 155 | ### Fixed 156 | 157 | * Make sure that `inertia_share` properties are reset before each request 158 | 159 | ## [1.1.0] - 2019-10-24 160 | 161 | ### Changed 162 | 163 | * Switches mattr_accessor defaults to block syntax to allow pre Rails 5.2 compatibility 164 | 165 | ## [1.0.1] - 2019-10-23 166 | 167 | ### Fixed 168 | 169 | * Allow `Intertia.share` within a controller to access controller methods 170 | 171 | ## [1.0.0] - 2019-10-09 172 | 173 | * Initial release 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | For more instructions, see [Server-side setup](https://inertia-rails.netlify.app/guide/server-side-setup.html). 17 | 18 | ### Frontend 19 | 20 | We are discussing on bringing official docs for Inertia Rails to this repo, as 21 | the [official docs](https://inertiajs.com/client-side-setup) are specific to Laravel. 22 | 23 | In the meantime, you can refer to the community-maintained [Client-side setup](https://inertia-rails.netlify.app/guide/client-side-setup.html). 24 | 25 | Examples: 26 | 27 | - [React/Vite](https://github.com/BrandonShar/inertia-rails-template) 28 | - [React/Vite + SSR](https://github.com/ElMassimo/inertia-rails-ssr-template) 29 | - [PingCRM with Vue and Vite](https://github.com/ledermann/pingcrm) 30 | 31 | ## Usage 32 | 33 | ### Responses 34 | 35 | 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. 36 | 37 | ```ruby 38 | def index 39 | render inertia: 'Event/Index', props: { 40 | events: Event.all, 41 | } 42 | end 43 | ``` 44 | 45 | #### Rails Component and Instance Props 46 | 47 | Starting in version 3.0, Inertia Rails allows you to provide your component name and props via common rails conventions. 48 | 49 | ```ruby 50 | class EventsController < ApplicationController 51 | use_inertia_instance_props 52 | 53 | def index 54 | @events = Event.all 55 | end 56 | 57 | end 58 | ``` 59 | 60 | is the same as 61 | 62 | 63 | ```ruby 64 | class EventsController < ApplicationController 65 | def index 66 | render inertia: 'events/index', props: { 67 | events: Event.all 68 | } 69 | end 70 | end 71 | ``` 72 | 73 | #### Instance Props and Default Render Notes 74 | 75 | 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 76 | 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`. 77 | 78 | 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. 79 | 80 | ### Layout 81 | 82 | Inertia layouts use the rails layout convention and can be set or changed in the same way. 83 | 84 | ```ruby 85 | class EventsController < ApplicationController 86 | layout 'inertia_application' 87 | end 88 | ``` 89 | 90 | 91 | ### Shared Data 92 | 93 | 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 `shared_data` controller method. 94 | 95 | ```ruby 96 | class EventsController < ApplicationController 97 | # share syncronously 98 | inertia_share app_name: env['app.name'] 99 | 100 | # share lazily, evaluated at render time 101 | inertia_share do 102 | if logged_in? 103 | { 104 | user: logged_in_user, 105 | } 106 | end 107 | end 108 | 109 | # share lazily alternate syntax 110 | inertia_share user_count: lambda { User.count } 111 | 112 | end 113 | ``` 114 | 115 | #### Deep Merging Shared Data 116 | 117 | 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. 118 | 119 | ```ruby 120 | class ApplicationController 121 | inertia_share do 122 | { basketball_data: { points: 50, rebounds: 100 } } 123 | end 124 | end 125 | ``` 126 | 127 | Let's say we want a particular action to change only part of that data structure. The renderer accepts a `deep_merge` option: 128 | 129 | ```ruby 130 | class CrazyScorersController < ApplicationController 131 | def index 132 | render inertia: 'CrazyScorersComponent', 133 | props: { basketball_data: { points: 100 } }, 134 | deep_merge: true 135 | end 136 | end 137 | 138 | # The renderer will send this to the frontend: 139 | { 140 | basketball_data: { 141 | points: 100, 142 | rebounds: 100, 143 | } 144 | } 145 | ``` 146 | 147 | Deep merging can be configured using the [`deep_merge_shared_data`](#deep_merge_shared_data) configuration option. 148 | 149 | If deep merging is enabled, you can still opt-out within the action: 150 | 151 | ```ruby 152 | class CrazyScorersController < ApplicationController 153 | inertia_config(deep_merge_shared_data: true) 154 | 155 | inertia_share do 156 | { 157 | basketball_data: { 158 | points: 50, 159 | rebounds: 10, 160 | } 161 | } 162 | end 163 | 164 | def index 165 | render inertia: 'CrazyScorersComponent', 166 | props: { basketball_data: { points: 100 } }, 167 | deep_merge: false 168 | end 169 | end 170 | 171 | # `deep_merge: false` overrides the default: 172 | { 173 | basketball_data: { 174 | points: 100, 175 | } 176 | } 177 | ``` 178 | 179 | ### Lazy Props 180 | 181 | On the front end, 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 intial load. In this case, you can use Lazy props. Lazy props aren't evaluated unless they're specifically requested by name in a partial reload. 182 | 183 | ```ruby 184 | inertia_share some_data: InertiaRails.lazy(lambda { some_very_slow_method }) 185 | 186 | # Using a Ruby block syntax 187 | inertia_share some_data: InertiaRails.lazy { some_very_slow_method } 188 | ``` 189 | 190 | ### Routing 191 | 192 | If you don't need a controller to handle a static component, you can route directly to a component with the inertia route helper 193 | 194 | ```ruby 195 | inertia 'about' => 'AboutComponent' 196 | ``` 197 | 198 | ### SSR _(experimental)_ 199 | 200 | Enable SSR via the configuration options for [`ssr_enabled`](#ssr_enabled-experimental) and [`ssr_url`](#ssr_url-experimental). 201 | 202 | When using SSR, don't forget to add `<%= inertia_ssr_head %>` to the `` of your layout (i.e. `application.html.erb`). 203 | 204 | ## Configuration ⚙️ 205 | 206 | Inertia Rails can be configured globally or in a specific controller (and subclasses). 207 | 208 | ### Global Configuration 209 | 210 | If using global configuration, we recommend you place the code inside an initializer: 211 | 212 | ```ruby 213 | # config/initializers/inertia.rb 214 | 215 | InertiaRails.configure do |config| 216 | # Example: force a full-reload if the deployed assets change. 217 | config.version = ViteRuby.digest 218 | end 219 | ``` 220 | 221 | The default configuration can be found [here](https://github.com/inertiajs/inertia-rails/blob/master/lib/inertia_rails/configuration.rb#L5-L22). 222 | 223 | ### Local Configuration 224 | 225 | Use `inertia_config` in your controllers to override global settings: 226 | 227 | ```ruby 228 | class EventsController < ApplicationController 229 | inertia_config( 230 | version: "events-#{InertiaRails.configuration.version}", 231 | ssr_enabled: -> { action_name == "index" }, 232 | ) 233 | end 234 | ``` 235 | 236 | ### Configuration Options 237 | 238 | #### `version` _(recommended)_ 239 | 240 | This allows Inertia to detect if the app running in the client is oudated, 241 | forcing a full page visit instead of an XHR visit on the next request. 242 | 243 | See [assets versioning](https://inertiajs.com/asset-versioning). 244 | 245 | __Default__: `nil` 246 | 247 | #### `deep_merge_shared_data` 248 | 249 | When enabled, props will be deep merged with shared data, combining hashes 250 | with the same keys instead of replacing them. 251 | 252 | __Default__: `false` 253 | 254 | #### `default_render` 255 | 256 | Overrides Rails default rendering behavior to render using Inertia by default. 257 | 258 | __Default__: `false` 259 | 260 | #### `ssr_enabled` _(experimental)_ 261 | 262 | Whether to use a JavaScript server to pre-render your JavaScript pages, 263 | allowing your visitors to receive fully rendered HTML when they first visit 264 | your application. 265 | 266 | Requires a JS server to be available at `ssr_url`. [_Example_](https://github.com/ElMassimo/inertia-rails-ssr-template) 267 | 268 | __Default__: `false` 269 | 270 | #### `ssr_url` _(experimental)_ 271 | 272 | The URL of the JS server that will pre-render the app using the specified 273 | component and props. 274 | 275 | __Default__: `"http://localhost:13714"` 276 | 277 | ## Testing 278 | 279 | If you're using Rspec, Inertia Rails comes with some nice test helpers to make things simple. 280 | 281 | To use these helpers, just add the following require statement to your `spec/rails_helper.rb` 282 | 283 | ```ruby 284 | require 'inertia_rails/rspec' 285 | ``` 286 | 287 | And in any test you want to use the inertia helpers, add the inertia flag to the describe block 288 | 289 | ```ruby 290 | RSpec.describe EventController, type: :request do 291 | describe '#index', inertia: true do 292 | # ... 293 | end 294 | end 295 | ``` 296 | 297 | ### Assertions 298 | 299 | ```ruby 300 | RSpec.describe EventController, type: :request do 301 | describe '#index', inertia: true do 302 | 303 | # check the component 304 | expect_inertia.to render_component 'Event/Index' 305 | 306 | # access the component name 307 | expect(inertia.component).to eq 'TestComponent' 308 | 309 | # props (including shared props) 310 | expect_inertia.to have_exact_props({name: 'Brandon', sport: 'hockey'}) 311 | expect_inertia.to include_props({sport: 'hockey'}) 312 | 313 | # access props 314 | expect(inertia.props[:name]).to eq 'Brandon' 315 | 316 | # view data 317 | expect_inertia.to have_exact_view_data({name: 'Brian', sport: 'basketball'}) 318 | expect_inertia.to include_view_data({sport: 'basketball'}) 319 | 320 | # access view data 321 | expect(inertia.view_data[:name]).to eq 'Brian' 322 | 323 | end 324 | end 325 | 326 | ``` 327 | 328 | *Maintained and sponsored by the team at [bellaWatt](https://bellawatt.com/)* 329 | 330 | [![bellaWatt Logo](https://user-images.githubusercontent.com/6599653/114456832-5607d980-9bab-11eb-99c8-ab39867c384e.png)](https://bellawatt.com/) 331 | --------------------------------------------------------------------------------