Skip to content

feat(csp): Remove inline script from the preview frame - #4334

Merged
tvdeyen merged 8 commits into
mainfrom
preview-without-globals
Oct 16, 2026
Merged

tvdeyen merged 8 commits into
mainfrom
preview-without-globals

Conversation

@tvdeyen

@tvdeyen tvdeyen commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

What is this pull request for?

This continues the work from #4315 of removing executable inline scripts that only carry data, so host applications can run a strict content security policy.

In preview mode the alchemy/preview_mode_code partial rendered an inline script on every frontend page, solely to define a global Alchemy object. That object only existed so the bare Alchemy reference on the first line of the preview bundle would resolve, and its one payload was a locale nothing reads, since the admin resolves the current locale from the html lang attribute. The element selector now lives in module scope, so the inline script is gone entirely and host applications no longer have to allow it.

Notable changes

The preview frame no longer defines window.Alchemy, so Alchemy.ElementSelector and Alchemy.locale are gone from it. Neither was documented or used outside the preview script, but a host application that reached into the preview frame for them would notice. The Alchemy.* postMessage names are untouched, since those are the protocol shared with the admin window rather than globals.

Review surfaced a latent bug, fixed here as well. The preview script is render blocking and runs wherever the host layout places the alchemy/edit_mode partial, so any element rendered below that partial did not exist yet when the script collected its elements and was silently not selectable. The script now waits for DOMContentLoaded. The dummy layout renders the partial above its content on purpose, so the system specs cover the arrangement that used to break.

The preview no longer adds a selected class to the host application's markup. It only served to remember the selected element, and deselecting removed selected from every element in the preview, including a class the host application had set for its own purposes.

Only clicks that navigate are blocked now. Clicks on links and form submit buttons inside an element are taken before the page handles them, so client side routers can no longer navigate the preview frame away: RouterLink, NuxtLink and React Router's Link render an anchor and navigate from its click handler, which used to run before the preview script saw the click. Every other click reaches the page untouched and selects its element on the way back up, so interactive content such as tabs or carousel controls, and native behavior such as toggling <details> or checkboxes, work in the preview. Previously every click inside an element was default prevented. Links rendered through the custom slot of RouterLink or NuxtLink without an anchor are not intercepted.

The preview spec runs as its own Vitest project, separate from the admin one, because the preview bundle ships to the frontend where none of the globals the admin setup file defines exist.

Checklist

  • I have followed Pull Request guidelines
  • I have added a detailed description into each commit message
  • I have added tests to cover this change

@tvdeyen
tvdeyen requested a review from a team as a code owner October 9, 2026 15:09
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.40%. Comparing base (01f36b0) to head (c1fa5f3).
⚠️ Report is 9 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4334   +/-   ##
=======================================
  Coverage   98.40%   98.40%           
=======================================
  Files         371      371           
  Lines        9763     9763           
=======================================
  Hits         9607     9607           
  Misses        156      156           
Flag Coverage Δ
mariadb 97.01% <ø> (ø)
mysql 95.92% <ø> (ø)
postgresql 95.92% <ø> (ø)
sqlite 95.92% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tvdeyen
tvdeyen force-pushed the preview-without-globals branch from a3262e2 to cba1235 Compare October 12, 2026 20:12
@tvdeyen tvdeyen added the backport-to-8.4-stable Needs to be backported to 8.4-stable label Oct 14, 2026
@tvdeyen tvdeyen added this to the 8.4 milestone Oct 14, 2026
@tvdeyen tvdeyen added the enhancement New feature or enhancement label Oct 14, 2026
@tvdeyen
tvdeyen force-pushed the preview-without-globals branch from 829921c to bd2d284 Compare October 14, 2026 12:07
The bundle opened by reading a bare Alchemy global, which only resolved
because the preview partial rendered an inline script to define it. That
forced host applications running their own content security policy to
allow an inline script on every frontend page.

Nothing outside this file ever used the element selector it hung off that
global, so module level state serves it just as well. Without a namespace
object to attach methods to, plain functions over module state replace the
object literal and the shared this it existed to provide.

The preview bundle ships to the frontend, where none of the globals the
admin setup file defines exist, so its spec runs as its own test project
rather than borrowing the admin environment.
With the bundle no longer reading a global, the inline script the partial
rendered has nothing left to define: its only payload was a locale that
nothing reads, since the admin resolves the current locale from the html
lang attribute. Removing it takes an inline script off every frontend page
of host applications that render the alchemy/edit_mode partial.
The preview script had no coverage at all, so nothing caught a break in
the message protocol it shares with the admin window. These examples drive
the real iframe on the page edit screen and assert both directions:
clicking an element in the preview selects its editor, and clicking an
editor selects the element in the preview.

Exercising the built bundle in a browser also guards the assumption that
the preview frame needs no inline script to boot.
The preview script is render blocking and host layouts decide where the
alchemy/edit_mode partial goes, so any page content rendered below it did
not exist yet when the script collected its elements. Those elements were
silently not selectable in the preview frame.

Waiting for DOMContentLoaded fixes that, guarded by a readyState check
because the event has already fired when the script loads late. The dummy
layout now renders the partial above its content, so the system specs
cover the arrangement that used to break.
The preview wrote a selected class onto host application markup, but
nothing styles or reads it: the script only used it to remember which
element to leave alone on hover. Worse, deselecting removed the class from
every preview element, stripping a selected class the host application may
have put there for its own purposes. A module level reference keeps that
state without touching the host's markup.
The click listener sat on each element in the bubble phase, so handlers
attached to links inside an element ran first. Client side routers like
vue-router bind their click handler directly to the link and only skip
navigation once the event was default prevented, which it was not yet,
so clicking a link in the preview navigated the frame away.

A single click listener on the window in the capture phase runs before
any handler in the page. Capturing on the elements themselves would have
reached the outermost of nested elements first, so the listener resolves
the innermost element from the event target instead. Interactive content
inside an element no longer receives clicks in the preview, consistent
with links already being inert there.
Capturing every click inside an element kept routers from navigating, but
it also starved the page's own interactive content: tabs, accordions and
carousel controls inside an element stopped reacting in the preview,
where they used to work and select their element at the same time.

Navigation only starts from links. RouterLink, NuxtLink and React
Router's Link all render an anchor and navigate from its click handler,
so only clicks inside an anchor are taken in the capture phase. Every
other click reaches the page first and selects its element on the way out
of the bubble phase, as before. Links rendered through the custom slot of
RouterLink or NuxtLink without an anchor are not intercepted.
Every click inside an element was default prevented after the page had
handled it, so native behavior like toggling details or checking a
checkbox never happened in the preview. Only navigation needs blocking.

Clicks on links and on form submit buttons navigate the frame away, so
those are taken in the capture phase as before. Every other click now
only selects its element and keeps its default behavior. A button only
counts as a submit button when it belongs to a form, since a button
without a type reports submit even when it submits nothing.
@tvdeyen
tvdeyen force-pushed the preview-without-globals branch from bd2d284 to c1fa5f3 Compare October 16, 2026 11:14
@tvdeyen
tvdeyen enabled auto-merge October 16, 2026 11:14
@netlify

netlify Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for alchemy-guides canceled.

Name Link
🔨 Latest commit c1fa5f3
🔍 Latest deploy log https://app.netlify.com/projects/alchemy-guides/deploys/6ad207079ad0a2dabb05d260

@tvdeyen
tvdeyen merged commit 395eb0d into main Oct 16, 2026
39 checks passed
@tvdeyen
tvdeyen deleted the preview-without-globals branch October 16, 2026 11:27
@alchemycms-bot

Copy link
Copy Markdown

💚 All backports created successfully

Status Branch Result
✅ 8.4-stable

Questions ?

Please refer to the Backport tool documentation and see the Github Action logs for details

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-to-8.4-stable Needs to be backported to 8.4-stable enhancement New feature or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants