diff --git a/.changeset/six-ducks-sit.md b/.changeset/six-ducks-sit.md new file mode 100644 index 000000000000..3a80ecd8ccac --- /dev/null +++ b/.changeset/six-ducks-sit.md @@ -0,0 +1,11 @@ +--- +'astro': patch +--- + +**BREAKING CHANGE to the experimental Content Security Policy feature only** + +The `ClientRouter` component doesn't support CSP anymore. Supporting CSP meant to +to make the underling implementation of view transition asynchronous, which caused +some breaking changes to users. + +The support might be introduced in future releases. diff --git a/packages/astro/e2e/csp-view-transitions.test.js b/packages/astro/e2e/csp-view-transitions.test.js deleted file mode 100644 index c7f3ac9aa0f5..000000000000 --- a/packages/astro/e2e/csp-view-transitions.test.js +++ /dev/null @@ -1,1626 +0,0 @@ -import { expect } from '@playwright/test'; -import { testFactory } from './test-utils.js'; - -const test = testFactory(import.meta.url, { - root: './fixtures/view-transitions/', - experimental: { - csp: true, - }, -}); - -let previewServer; - -test.beforeAll(async ({ astro }) => { - await astro.build(); - previewServer = await astro.preview(); -}); - -test.afterAll(async () => { - await previewServer.stop(); -}); -function collectLoads(page) { - const loads = []; - page.on('load', async () => { - const url = page.url(); - if (url !== 'about:blank') loads.push(await page.title()); - }); - return loads; -} -function scrollToBottom(page) { - return page.evaluate(() => { - window.scrollY = document.documentElement.scrollHeight; - window.dispatchEvent(new Event('scroll')); - }); -} - -function collectPreloads(page) { - return page.evaluate(() => { - window.preloads = []; - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => - mutation.addedNodes.forEach((node) => { - if (node.nodeName === 'LINK' && node.rel === 'preload') preloads.push(node.href); - }), - ); - }); - observer.observe(document.head, { childList: true }); - }); -} - -async function nativeViewTransition(page) { - return page.evaluate(() => document.startViewTransition !== undefined); -} - -test.describe('CSP View Transitions', () => { - test('Moving from page 1 to page 2', async ({ page, astro }) => { - const loads = collectLoads(page); - - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // go to page 2 - await page.click('#click-two'); - p = page.locator('#two'); - await expect(p, 'should have content').toHaveText('Page 2'); - - expect(loads.length, 'There should only be 1 page load').toEqual(1); - }); - - test('Back button is captured', async ({ page, astro }) => { - const loads = collectLoads(page); - - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // go to page 2 - await page.click('#click-two'); - p = page.locator('#two'); - await expect(p, 'should have content').toHaveText('Page 2'); - - // Back to page 1 - await page.goBack(); - p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - expect(loads.length, 'There should only be 1 page load').toEqual(1); - }); - - test('Clicking on a link with nested content', async ({ page, astro }) => { - const loads = collectLoads(page); - // Go to page 4 - await page.goto(astro.resolveUrl('/four')); - let p = page.locator('#four'); - await expect(p, 'should have content').toHaveText('Page 4'); - - // Go to page 1 - await page.click('#click-one'); - p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - expect(loads.length, 'There should only be 1 page load').toEqual(1); - }); - - test('Clicking on a link to a page with non-recommended headers', async ({ page, astro }) => { - const loads = collectLoads(page); - // Go to page 4 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // Go to page 1 - await page.click('#click-seven'); - p = page.locator('#seven'); - await expect(p, 'should have content').toHaveText('Page 7'); - - expect(loads.length, 'There should only be 1 page load').toEqual(1); - }); - - test('Moving to a page without ClientRouter triggers a full page navigation', async ({ - page, - astro, - }) => { - const loads = collectLoads(page); - - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // Go to page 3 which does *not* have ClientRouter enabled - await page.click('#click-three'); - p = page.locator('#three'); - await expect(p, 'should have content').toHaveText('Page 3'); - - expect( - loads.length, - 'There should be 2 page loads. The original, then going from 3 to 2', - ).toEqual(2); - }); - - test('Moving within a page without ClientRouter does not trigger a full page navigation', async ({ - page, - astro, - }) => { - const loads = collectLoads(page); - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // Go to page 3 which does *not* have ClientRouter enabled - await page.click('#click-three'); - p = page.locator('#three'); - await expect(p, 'should have content').toHaveText('Page 3'); - - // click a hash link to navigate further down the page - await page.click('#click-hash'); - // still on page 3 - p = page.locator('#three'); - await expect(p, 'should have content').toHaveText('Page 3'); - - expect( - loads.length, - 'There should be only 2 page loads (for page one & three), but no additional loads for the hash change', - ).toEqual(2); - }); - - test('Moving from a page without ClientRouter w/ back button', async ({ page, astro }) => { - const loads = collectLoads(page); - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // Go to page 3 which does *not* have ClientRouter enabled - await page.click('#click-three'); - p = page.locator('#three'); - await expect(p, 'should have content').toHaveText('Page 3'); - - // Back to page 1 - await page.goBack(); - p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - expect( - loads.length, - 'There should be 3 page loads (for page one & three), and an additional loads for the back navigation', - ).toEqual(3); - }); - - test('Stylesheets in the head are waited on', async ({ page, astro }) => { - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - await collectPreloads(page); - - // Go to page 2 - await page.click('#click-two'); - p = page.locator('#two'); - await expect(p, 'should have content').toHaveText('Page 2'); - await expect(p, 'imported CSS updated').toHaveCSS('font-size', '24px'); - const preloads = await page.evaluate(() => window.preloads); - expect(preloads.length === 1 && preloads[0].endsWith('/two.css')).toBeTruthy(); - }); - - test('astro:page-load event fires when navigating to new page', async ({ page, astro }) => { - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - const p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // go to page 2 - await page.click('#click-two'); - const article = page.locator('#twoarticle'); - await expect(article, 'should have script content').toHaveText('works'); - }); - - test('astro:page-load event fires when navigating directly to a page', async ({ - page, - astro, - }) => { - // Go to page 2 - await page.goto(astro.resolveUrl('/two')); - const article = page.locator('#twoarticle'); - await expect(article, 'should have script content').toHaveText('works'); - }); - - test('astro:after-swap event fires right after the swap', async ({ page, astro }) => { - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // go to page 2 - await page.click('#click-two'); - p = page.locator('#two'); - const h = page.locator('html'); - await expect(h, 'imported CSS updated').toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); - }); - - test('No page rendering during swap()', async ({ page, astro }) => { - // This has been a problem with theme switchers (e.g. for darkmode) - // Swap() should not trigger any page renders and give users the chance to - // correct attributes in the astro:after-swap handler before they become visible - - // This test uses a CSS animation to detect page rendering - // The test succeeds if no additional animation beside those of the - // view transition is triggered during swap() - - // Only works for browsers with native view transitions - if (!(await nativeViewTransition(page))) return; - - await page.goto(astro.resolveUrl('/listener-one')); - let p = page.locator('#totwo'); - await expect(p, 'should have content').toHaveText('Go to listener two'); - - // setting the blue class on the html element triggers a CSS animation - let animations = await page.evaluate(async () => { - document.documentElement.classList.add('blue'); - return document.getAnimations(); - }); - expect(animations.length).toEqual(1); - - // go to page 2 - await page.click('#totwo'); - p = page.locator('#toone'); - await expect(p, 'should have content').toHaveText('Go to listener one'); - // swap() resets the "blue" class, as it is not set in the static html of page 2 - // The astro:after-swap listener (defined in the layout) sets it to "blue" again. - // The temporarily missing class must not trigger page rendering. - - // When the after-swap listener starts, no animations should be running - // after-swap listener sets animations to document.getAnimations().length - // and we expect this to be zero - await expect(page.locator('html')).toHaveAttribute('animations', '0'); - }); - - test('click hash links does not do navigation', async ({ page, astro }) => { - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - const p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // Clicking 1 stays put - await page.click('#click-one'); - await expect(p, 'should have content').toHaveText('Page 1'); - }); - - test('click self link (w/o hash) does not do navigation', async ({ page, astro }) => { - const loads = collectLoads(page); - - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - const p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // Clicking href="" stays on page - await page.click('#click-self'); - await expect(p, 'should have content').toHaveText('Page 1'); - expect(loads.length, 'There should only be 1 page load').toEqual(1); - }); - - test('Scroll position restored on back button', async ({ page, astro }) => { - // Go to page 1 - await page.goto(astro.resolveUrl('/long-page')); - let article = page.locator('#longpage'); - await expect(article, 'should have script content').toBeVisible('exists'); - - await scrollToBottom(page); - const oldScrollY = await page.evaluate(() => window.scrollY); - - // go to page long-page - await page.click('#click-one'); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // Back to page 1 - await page.goBack(); - - const newScrollY = await page.evaluate(() => window.scrollY); - expect(oldScrollY).toEqual(newScrollY); - }); - - test('Fragment scroll position restored on back button', async ({ page, astro }) => { - // Go to the long page - await page.goto(astro.resolveUrl('/long-page')); - let locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - - // Scroll down to middle fragment - await page.click('#click-scroll-down'); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - - // Scroll up to top fragment - await page.click('#click-scroll-up'); - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - - // Back to middle of the page - await page.goBack(); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - }); - - test('Scroll position restored when transitioning back to fragment', async ({ page, astro }) => { - // Go to the long page - await page.goto(astro.resolveUrl('/long-page')); - let locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - - // Scroll down to middle fragment - await page.click('#click-scroll-down'); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - - // goto page 1 - await page.click('#click-one-again'); - locator = page.locator('#one'); - await expect(locator).toHaveText('Page 1'); - - // Back to middle of the previous page - await page.goBack(); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - }); - - test('Scroll position restored on forward button', async ({ page, astro }) => { - // Go to page 1 - await page.goto(astro.resolveUrl('/one')); - let p = page.locator('#one'); - await expect(p, 'should have content').toHaveText('Page 1'); - - // go to page long-page - await page.click('#click-longpage'); - let article = page.locator('#longpage'); - await expect(article, 'should have script content').toBeVisible('exists'); - - await scrollToBottom(page); - const oldScrollY = await page.evaluate(() => window.scrollY); - - // Back to page 1 - await page.goBack(); - - // Go forward - await page.goForward(); - article = page.locator('#longpage'); - await expect(article, 'should have script content').toBeVisible('exists'); - - const newScrollY = await page.evaluate(() => window.scrollY); - expect(oldScrollY).toEqual(newScrollY); - }); - - test('Fragment scroll position restored on forward button', async ({ page, astro }) => { - // Go to the long page - await page.goto(astro.resolveUrl('/long-page')); - let locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - - // Scroll down to middle fragment - await page.click('#click-scroll-down'); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - - // Scroll back to top - await page.goBack(); - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - - // Forward to middle of page - await page.goForward(); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - }); - - // We don't support inline scripts yet - test.skip('View Transitions Rule', async ({ page, astro }) => { - let consoleCount = 0; - page.on('console', (msg) => { - // This count is used for transition events - if (msg.text() === 'ready') consoleCount++; - }); - // Don't test back and forward '' to '', because They are not stored in the history. - // click '' to '' (transition) - await page.goto(astro.resolveUrl('/long-page')); - let locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - let consolePromise = page.waitForEvent('console'); - await page.click('#click-self'); - await consolePromise; - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(1); - - // click '' to 'hash' (no transition) - await page.click('#click-scroll-down'); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(1); - - // back 'hash' to '' (no transition) - await page.goBack(); - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(1); - - // forward '' to 'hash' (no transition) - // NOTE: the networkidle below is needed for Firefox to consistently - // pass the `#longpage` viewport check below - await page.goForward({ waitUntil: 'networkidle' }); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(1); - - // click 'hash' to 'hash' (no transition) - await page.click('#click-scroll-up'); - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(1); - - // back 'hash' to 'hash' (no transition) - await page.goBack(); - locator = page.locator('#click-one-again'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(1); - - // forward 'hash' to 'hash' (no transition) - await page.goForward(); - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(1); - - // click 'hash' to '' (transition) - consolePromise = page.waitForEvent('console'); - await page.click('#click-self'); - await consolePromise; - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(2); - - // back '' to 'hash' (transition) - consolePromise = page.waitForEvent('console'); - await page.goBack(); - await consolePromise; - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(3); - - // forward 'hash' to '' (transition) - consolePromise = page.waitForEvent('console'); - await page.goForward(); - await consolePromise; - locator = page.locator('#longpage'); - await expect(locator).toBeInViewport(); - expect(consoleCount).toEqual(4); - }); - - test(' component forwards transitions to the ', async ({ page, astro }) => { - // Go to page 1 - await page.goto(astro.resolveUrl('/image-one')); - const img = page.locator('img[data-astro-transition-scope]'); - await expect(img).toBeVisible('The image tag should have the transition scope attribute.'); - }); - - test('