FEATURE: Preload resources via link header (#18475)
Experiment moving from preload tags in the document head to preload information the the response headers. While this is a minor improvement in most browsers (headers are parsed before the response body), this allows smart proxies like Cloudflare to "learn" from those headers and build HTTP 103 Early Hints for subsequent requests to the same URI, which will allow the user agent to download and parse our JS/CSS while we are waiting for the server to generate and stream the HTML response. Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
This commit is contained in:
parent
a1d67122b1
commit
2d1dbc6f96
|
@ -49,7 +49,8 @@ class ApplicationController < ActionController::Base
|
|||
after_action :conditionally_allow_site_embedding
|
||||
after_action :ensure_vary_header
|
||||
after_action :add_noindex_header, if: -> { is_feed_request? || !SiteSetting.allow_index_in_robots_txt }
|
||||
after_action :add_noindex_header_to_non_canonical, if: -> { request.get? && !(request.format && request.format.json?) && !request.xhr? }
|
||||
after_action :add_noindex_header_to_non_canonical, if: :spa_boot_request?
|
||||
around_action :link_preload, if: :spa_boot_request?
|
||||
|
||||
HONEYPOT_KEY ||= 'HONEYPOT_KEY'
|
||||
CHALLENGE_KEY ||= 'CHALLENGE_KEY'
|
||||
|
@ -1008,4 +1009,14 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
result
|
||||
end
|
||||
|
||||
def link_preload
|
||||
@links_to_preload = []
|
||||
yield
|
||||
response.headers['Link'] = @links_to_preload.join(', ') if !@links_to_preload.empty?
|
||||
end
|
||||
|
||||
def spa_boot_request?
|
||||
request.get? && !(request.format && request.format.json?) && !request.xhr?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -142,12 +142,16 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def preload_script_url(url)
|
||||
add_resource_preload_list(url, 'script')
|
||||
<<~HTML.html_safe
|
||||
<link rel="preload" href="#{url}" as="script">
|
||||
<script defer src="#{url}"></script>
|
||||
HTML
|
||||
end
|
||||
|
||||
def add_resource_preload_list(resource_url, type)
|
||||
@links_to_preload << %Q(<#{resource_url}>; rel="preload"; as="#{type}") if !@links_to_preload.nil?
|
||||
end
|
||||
|
||||
def discourse_csrf_tags
|
||||
# anon can not have a CSRF token cause these are all pages
|
||||
# that may be cached, causing a mismatch between session CSRF
|
||||
|
@ -589,7 +593,7 @@ module ApplicationHelper
|
|||
stylesheet_manager
|
||||
end
|
||||
|
||||
manager.stylesheet_link_tag(name, 'all')
|
||||
manager.stylesheet_link_tag(name, 'all', self.method(:add_resource_preload_list))
|
||||
end
|
||||
|
||||
def discourse_preload_color_scheme_stylesheets
|
||||
|
@ -605,10 +609,10 @@ module ApplicationHelper
|
|||
|
||||
def discourse_color_scheme_stylesheets
|
||||
result = +""
|
||||
result << stylesheet_manager.color_scheme_stylesheet_link_tag(scheme_id, 'all')
|
||||
result << stylesheet_manager.color_scheme_stylesheet_link_tag(scheme_id, 'all', self.method(:add_resource_preload_list))
|
||||
|
||||
if dark_scheme_id != -1
|
||||
result << stylesheet_manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)')
|
||||
result << stylesheet_manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)', self.method(:add_resource_preload_list))
|
||||
end
|
||||
|
||||
result.html_safe
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
|
||||
<%= build_plugin_html 'server:before-script-load' %>
|
||||
|
||||
<link rel="preload" href="<%= script_asset_path "start-discourse" %>" as="script">
|
||||
<link rel="preload" href="<%= script_asset_path "browser-update" %>" as="script">
|
||||
<% add_resource_preload_list(script_asset_path("start-discourse"), "script") %>
|
||||
<% add_resource_preload_list(script_asset_path("browser-update"), "script") %>
|
||||
<%= preload_script 'browser-detect' %>
|
||||
|
||||
<%= preload_script "locales/#{I18n.locale}" %>
|
||||
|
|
|
@ -196,10 +196,11 @@ class Stylesheet::Manager
|
|||
end.join("\n").html_safe
|
||||
end
|
||||
|
||||
def stylesheet_link_tag(target = :desktop, media = 'all')
|
||||
def stylesheet_link_tag(target = :desktop, media = 'all', preload_callback = nil)
|
||||
stylesheets = stylesheet_details(target, media)
|
||||
stylesheets.map do |stylesheet|
|
||||
href = stylesheet[:new_href]
|
||||
preload_callback.call(href, 'style') if preload_callback
|
||||
theme_id = stylesheet[:theme_id]
|
||||
data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : ""
|
||||
theme_name = stylesheet[:theme_name]
|
||||
|
@ -311,12 +312,13 @@ class Stylesheet::Manager
|
|||
%[<link href="#{href}" rel="preload" as="style"/>].html_safe
|
||||
end
|
||||
|
||||
def color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = 'all')
|
||||
def color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = 'all', preload_callback = nil)
|
||||
stylesheet = color_scheme_stylesheet_details(color_scheme_id, media)
|
||||
|
||||
return '' if !stylesheet
|
||||
|
||||
href = stylesheet[:new_href]
|
||||
preload_callback.call(href, 'style') if preload_callback
|
||||
|
||||
css_class = media == 'all' ? "light-scheme" : "dark-scheme"
|
||||
|
||||
|
|
|
@ -3,9 +3,8 @@
|
|||
|
||||
RSpec.describe ApplicationHelper do
|
||||
describe "preload_script" do
|
||||
def preload_link(url)
|
||||
def script_tag(url)
|
||||
<<~HTML
|
||||
<link rel="preload" href="#{url}" as="script">
|
||||
<script defer src="#{url}"></script>
|
||||
HTML
|
||||
end
|
||||
|
@ -32,7 +31,7 @@ RSpec.describe ApplicationHelper do
|
|||
helper.request.env["HTTP_ACCEPT_ENCODING"] = 'br'
|
||||
link = helper.preload_script('discourse')
|
||||
|
||||
expect(link).to eq(preload_link("https://awesome.com/brotli_asset/discourse.js"))
|
||||
expect(link).to eq(script_tag("https://awesome.com/brotli_asset/discourse.js"))
|
||||
end
|
||||
|
||||
context "with s3 CDN" do
|
||||
|
@ -61,36 +60,77 @@ RSpec.describe ApplicationHelper do
|
|||
helper.request.env["HTTP_ACCEPT_ENCODING"] = 'br'
|
||||
link = helper.preload_script('discourse')
|
||||
|
||||
expect(link).to eq(preload_link("https://s3cdn.com/assets/discourse.br.js"))
|
||||
expect(link).to eq(script_tag("https://s3cdn.com/assets/discourse.br.js"))
|
||||
end
|
||||
|
||||
it "gives s3 cdn if asset host is not set" do
|
||||
link = helper.preload_script('discourse')
|
||||
|
||||
expect(link).to eq(preload_link("https://s3cdn.com/assets/discourse.js"))
|
||||
expect(link).to eq(script_tag("https://s3cdn.com/assets/discourse.js"))
|
||||
end
|
||||
|
||||
it "can fall back to gzip compression" do
|
||||
helper.request.env["HTTP_ACCEPT_ENCODING"] = 'gzip'
|
||||
link = helper.preload_script('discourse')
|
||||
expect(link).to eq(preload_link("https://s3cdn.com/assets/discourse.gz.js"))
|
||||
expect(link).to eq(script_tag("https://s3cdn.com/assets/discourse.gz.js"))
|
||||
end
|
||||
|
||||
it "gives s3 cdn even if asset host is set" do
|
||||
set_cdn_url "https://awesome.com"
|
||||
link = helper.preload_script('discourse')
|
||||
|
||||
expect(link).to eq(preload_link("https://s3cdn.com/assets/discourse.js"))
|
||||
expect(link).to eq(script_tag("https://s3cdn.com/assets/discourse.js"))
|
||||
end
|
||||
|
||||
it "gives s3 cdn but without brotli/gzip extensions for theme tests assets" do
|
||||
helper.request.env["HTTP_ACCEPT_ENCODING"] = 'gzip, br'
|
||||
link = helper.preload_script('discourse/tests/theme_qunit_ember_jquery')
|
||||
expect(link).to eq(preload_link("https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js"))
|
||||
expect(link).to eq(script_tag("https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "add_resource_preload_list" do
|
||||
it "adds resources to the preload list when it's available" do
|
||||
@links_to_preload = []
|
||||
add_resource_preload_list('/assets/discourse.js', 'script')
|
||||
add_resource_preload_list('/assets/discourse.css', 'style')
|
||||
|
||||
expect(@links_to_preload.size).to eq(2)
|
||||
end
|
||||
|
||||
it "doesn't add resources to the preload list when it's not available" do
|
||||
@links_to_preload = nil
|
||||
add_resource_preload_list('/assets/discourse.js', 'script')
|
||||
add_resource_preload_list('/assets/discourse.css', 'style')
|
||||
|
||||
expect(@links_to_preload).to eq(nil)
|
||||
end
|
||||
|
||||
it "adds resources to the preload list when preload_script is called" do
|
||||
@links_to_preload = []
|
||||
helper.preload_script('discourse')
|
||||
|
||||
expect(@links_to_preload.size).to eq(1)
|
||||
end
|
||||
|
||||
it "adds resources to the preload list when discourse_stylesheet_link_tag is called" do
|
||||
@links_to_preload = []
|
||||
helper.discourse_stylesheet_link_tag(:desktop)
|
||||
|
||||
expect(@links_to_preload.size).to eq(1)
|
||||
end
|
||||
|
||||
it "adds resources as the correct type" do
|
||||
@links_to_preload = []
|
||||
helper.discourse_stylesheet_link_tag(:desktop)
|
||||
helper.preload_script('discourse')
|
||||
|
||||
expect(@links_to_preload[0]).to match(/as="style"/)
|
||||
expect(@links_to_preload[1]).to match(/as="script"/)
|
||||
end
|
||||
end
|
||||
|
||||
describe "escape_unicode" do
|
||||
it "encodes tags" do
|
||||
expect(helper.escape_unicode("<tag>")).to eq("\u003ctag>")
|
||||
|
|
|
@ -148,6 +148,16 @@ RSpec.describe Stylesheet::Manager do
|
|||
})
|
||||
end
|
||||
|
||||
it "stylesheet_link_tag calls the preload callback when set" do
|
||||
preload_list = []
|
||||
preload_callback = ->(href, type) { preload_list << [href, type] }
|
||||
|
||||
manager = manager(theme.id)
|
||||
expect {
|
||||
manager.stylesheet_link_tag(:desktop_theme, 'all', preload_callback)
|
||||
}.to change(preload_list, :size)
|
||||
end
|
||||
|
||||
context "with stylesheet order" do
|
||||
let(:z_child_theme) do
|
||||
Fabricate(:theme, component: true, name: "ze component").tap do |z|
|
||||
|
@ -638,6 +648,17 @@ RSpec.describe Stylesheet::Manager do
|
|||
expect(details1[:new_href]).not_to eq(details2[:new_href])
|
||||
end
|
||||
|
||||
it "calls the preload callback when set" do
|
||||
preload_list = []
|
||||
cs = Fabricate(:color_scheme, name: 'Funky')
|
||||
theme = Fabricate(:theme, color_scheme_id: cs.id)
|
||||
preload_callback = ->(href, type) { preload_list << [href, type] }
|
||||
|
||||
expect {
|
||||
manager.color_scheme_stylesheet_link_tag(theme.id, 'all', preload_callback)
|
||||
}.to change(preload_list, :size).by(1)
|
||||
end
|
||||
|
||||
context "with theme colors" do
|
||||
let(:theme) { Fabricate(:theme).tap { |t|
|
||||
t.set_field(target: :common, name: "color_definitions", value: ':root {--special: rebeccapurple;}')
|
||||
|
|
|
@ -1124,4 +1124,16 @@ RSpec.describe ApplicationController do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'preload Link header' do
|
||||
it "should have the Link header with assets on full page requests" do
|
||||
get("/latest")
|
||||
expect(response.headers).to include('Link')
|
||||
end
|
||||
|
||||
it "shouldn't have the Link header on xhr api requests" do
|
||||
get("/latest.json")
|
||||
expect(response.headers).not_to include('Link')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue