diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index bf7d828eb8..384537b9ab 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -9,6 +9,7 @@
//= link components/map/map-test.geojson
//= link components/map/maplibre-gl-csp-worker.js
//= link views/travel-advice.js
+//= link lib/asset-manager-session.js
//= link static-error-pages.js
diff --git a/app/assets/javascripts/lib/asset-manager-session.js b/app/assets/javascripts/lib/asset-manager-session.js
new file mode 100644
index 0000000000..dbddec92d6
--- /dev/null
+++ b/app/assets/javascripts/lib/asset-manager-session.js
@@ -0,0 +1,33 @@
+/* Warms up the Asset Manager draft-assets session cookie via a single
+ * placeholder image request, then reloads any draft images already on
+ * the page once that request has settled (success or failure).
+ */
+/* istanbul ignore next */
+window.GOVUK = window.GOVUK || {};
+
+(function (root) {
+ function draftAssetImages () {
+ return [...document.images].filter((image) =>
+ image.src.includes('assets.') // currently, asset urls in draft preview point to their live link: 'assets.xyz' instead of 'draft-assets.xyz'. Created a backlog item for this: https://gov-uk.atlassian.net/browse/WHIT-4008.
+ )
+ }
+
+ function reloadDraftImages (draftAssets) {
+ draftAssets.forEach((image) => {
+ // re-setting src tries to fetch the image again, with the hope that a prior success is just served from cache
+ image.setAttribute('src', image.getAttribute('src'))
+ })
+ }
+
+ root.GOVUK.warmUpAssetManagerSession = function (placeholderAssetUrl) {
+ const draftAssets = draftAssetImages()
+ // fewer than 2 images means there's no concurrent-request race to fix, so nothing to warm up
+ if (draftAssets.length < 2) return
+
+ const reload = () => reloadDraftImages(draftAssets)
+ const placeholder = new Image()
+ placeholder.onload = reload
+ placeholder.onerror = reload
+ placeholder.src = placeholderAssetUrl
+ }
+}(window))
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index a916665a49..b7a978744b 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -24,4 +24,5 @@
<%= yield %>
+ <%= render "shared/asset_manager_session" %>
<% end %>
diff --git a/app/views/layouts/full_width.html.erb b/app/views/layouts/full_width.html.erb
index e04d2aa1a4..58b0be7822 100644
--- a/app/views/layouts/full_width.html.erb
+++ b/app/views/layouts/full_width.html.erb
@@ -14,4 +14,5 @@
<%= yield %>
+ <%= render "shared/asset_manager_session" %>
<% end %>
diff --git a/app/views/layouts/header_content_sidebar.html.erb b/app/views/layouts/header_content_sidebar.html.erb
index 64220aa479..37c6498a86 100644
--- a/app/views/layouts/header_content_sidebar.html.erb
+++ b/app/views/layouts/header_content_sidebar.html.erb
@@ -60,5 +60,6 @@
<% end %>
+ <%= render "shared/asset_manager_session" %>
<% end %>
diff --git a/app/views/layouts/header_sidebar_content.html.erb b/app/views/layouts/header_sidebar_content.html.erb
index d686f56961..20646969ea 100644
--- a/app/views/layouts/header_sidebar_content.html.erb
+++ b/app/views/layouts/header_sidebar_content.html.erb
@@ -34,5 +34,6 @@
+ <%= render "shared/asset_manager_session" %>
<% end %>
diff --git a/app/views/layouts/manual.html.erb b/app/views/layouts/manual.html.erb
index 922f0f5a96..09f9ffb045 100644
--- a/app/views/layouts/manual.html.erb
+++ b/app/views/layouts/manual.html.erb
@@ -17,5 +17,6 @@
<%= yield %>
+ <%= render "shared/asset_manager_session" %>
<% end %>
diff --git a/app/views/shared/_asset_manager_session.html.erb b/app/views/shared/_asset_manager_session.html.erb
new file mode 100644
index 0000000000..6dfef79d48
--- /dev/null
+++ b/app/views/shared/_asset_manager_session.html.erb
@@ -0,0 +1,18 @@
+<% if draft_host? %>
+ <%
+ host = if GovukEnvironment.current == "staging"
+ "staging.publishing.service.gov.uk"
+ elsif GovukEnvironment.current == "integration"
+ "integration.publishing.service.gov.uk"
+ else
+ "publishing.service.gov.uk"
+ end
+ placeholder_asset_url = "https://draft-assets.#{host}/media/5e59279b86650c53b2cefbfe/placeholder.jpg"
+ %>
+ <%= javascript_include_tag "lib/asset-manager-session.js", integrity: false %>
+ <%= javascript_tag nonce: true do %>
+ window.addEventListener("load", function () {
+ GOVUK.warmUpAssetManagerSession('<%= placeholder_asset_url %>')
+ })
+ <% end %>
+<% end %>
diff --git a/spec/javascripts/lib/asset-manager-session.spec.js b/spec/javascripts/lib/asset-manager-session.spec.js
new file mode 100644
index 0000000000..4d15720fae
--- /dev/null
+++ b/spec/javascripts/lib/asset-manager-session.spec.js
@@ -0,0 +1,90 @@
+describe('GOVUK.warmUpAssetManagerSession', function () {
+ let originalImage
+
+ const addImage = (src) => {
+ const img = document.createElement('img')
+ img.src = src
+ document.body.appendChild(img)
+ return img
+ }
+
+ const trackReloads = (image) => {
+ const reloadedUrls = []
+ const originalSetAttribute = image.setAttribute.bind(image)
+ image.setAttribute = (name, value) => {
+ if (name === 'src') reloadedUrls.push(value)
+ originalSetAttribute(name, value)
+ }
+ return reloadedUrls
+ }
+
+ const stubImage = ({ succeeds }) => {
+ class FakeImage {
+ get src () {
+ return this._src
+ }
+
+ set src (value) {
+ this._src = value
+ succeeds
+ ? this.onload && this.onload()
+ : this.onerror && this.onerror()
+ }
+ }
+ window.Image = FakeImage
+ }
+
+ beforeEach(function () {
+ originalImage = window.Image
+ })
+
+ afterEach(function () {
+ window.Image = originalImage
+ document.querySelectorAll('img').forEach((img) => img.remove())
+ })
+
+ it('does nothing when fewer than two draft images are on the page', function () {
+ addImage('https://draft-assets.test.gov.uk/media/1/one.jpg')
+ stubImage({ succeeds: true })
+
+ GOVUK.warmUpAssetManagerSession('https://draft-assets.test.gov.uk/media/placeholder/placeholder.jpg')
+
+ expect(document.images[0].src).toEqual(
+ 'https://draft-assets.test.gov.uk/media/1/one.jpg'
+ )
+ })
+
+ it('retries all draft images once the placeholder request succeeds', function () {
+ const first = addImage('https://draft-assets.test.gov.uk/media/1/one.jpg')
+ const second = addImage('https://draft-assets.test.gov.uk/media/2/two.jpg?foo=bar')
+ const firstReloads = trackReloads(first)
+ const secondReloads = trackReloads(second)
+ stubImage({ succeeds: true })
+
+ GOVUK.warmUpAssetManagerSession('https://draft-assets.test.gov.uk/media/placeholder/placeholder.jpg')
+
+ expect(firstReloads).toEqual(['https://draft-assets.test.gov.uk/media/1/one.jpg'])
+ expect(secondReloads).toEqual(['https://draft-assets.test.gov.uk/media/2/two.jpg?foo=bar'])
+ })
+
+ it('also retries all draft images when the placeholder request errors', function () {
+ const first = addImage('https://draft-assets.test.gov.uk/media/1/one.jpg')
+ addImage('https://draft-assets.test.gov.uk/media/2/two.jpg')
+ const firstReloads = trackReloads(first)
+ stubImage({ succeeds: false })
+
+ GOVUK.warmUpAssetManagerSession('https://draft-assets.test.gov.uk/media/placeholder/placeholder.jpg')
+
+ expect(firstReloads).toEqual(['https://draft-assets.test.gov.uk/media/1/one.jpg'])
+ })
+
+ it('ignores non-asset-manager images when counting/retrying', function () {
+ const other = addImage('https://static.test.gov.uk/media/1/one.jpg')
+ addImage('https://draft-assets.test.gov.uk/media/2/two.jpg')
+ stubImage({ succeeds: true })
+
+ GOVUK.warmUpAssetManagerSession('https://draft-assets.test.gov.uk/media/placeholder/placeholder.jpg')
+
+ expect(other.src).toEqual('https://static.test.gov.uk/media/1/one.jpg')
+ })
+})