Repository navigation
feat(csp): Remove inline script from the preview frame - #4334
Merged
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tvdeyen
force-pushed
the
preview-without-globals
branch
from
October 12, 2026 20:12
a3262e2 to
cba1235
Compare
sascha-karnatz
approved these changes
Oct 13, 2026
tvdeyen
force-pushed
the
preview-without-globals
branch
from
October 14, 2026 12:07
829921c to
bd2d284
Compare
mamhoff
approved these changes
Oct 16, 2026
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
force-pushed
the
preview-without-globals
branch
from
October 16, 2026 11:14
bd2d284 to
c1fa5f3
Compare
tvdeyen
enabled auto-merge
October 16, 2026 11:14
✅ Deploy Preview for alchemy-guides canceled.
|
💚 All backports created successfully
Questions ?Please refer to the Backport tool documentation and see the Github Action logs for details |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_codepartial rendered an inline script on every frontend page, solely to define a globalAlchemyobject. That object only existed so the bareAlchemyreference 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 htmllangattribute. 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, soAlchemy.ElementSelectorandAlchemy.localeare 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. TheAlchemy.*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_modepartial, 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 forDOMContentLoaded. 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
selectedclass to the host application's markup. It only served to remember the selected element, and deselecting removedselectedfrom 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,NuxtLinkand React Router'sLinkrender 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 thecustomslot ofRouterLinkorNuxtLinkwithout 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