Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/opt-in-view-transition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': major
---

BREAKING: SPA view transitions are now opt-in; set `viewTransition={true}` on `QwikRouterProvider` to re-enable them.
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<!DOCTYPE html><html lang="en" q:render="static-ssr" q:route="/" q:container="paused" q:runtime="2" q:base="/build/" q:locale="" q:manifest-hash="MANIFEST_HASH" q:instance="[instance]" :><head :><link rel="modulepreload" href="/build/q-xxxxxxxx.js" :><script async type="module" src="/build/q-xxxxxxxx.js"></script><link rel="modulepreload" href="/build/q-xxxxxxxx.js" :><link rel="preload" href="/assets/xxxxxxxx-bundle-graph.json" as="fetch" crossorigin="anonymous" :><script type="module" async crossorigin="anonymous">let b=fetch("/assets/xxxxxxxx-bundle-graph.json");import("/build/q-xxxxxxxx.js").then(({l})=>l("/build/",b));</script><link rel="modulepreload" href="/build/q-xxxxxxxx.js" :><meta : charset="utf-8"><meta : name="viewport" content="width=device-width, initial-scale=1.0"><link : rel="canonical" href="https://snapshot.qwik.dev/"><title :="r5_0">Qwik Router SSG Snapshot</title><meta name="description" content="Deterministic SSG snapshot fixture" :="r5_1"><style :="ji2uyl-0" q:style="ji2uyl-0">@layer qwik{@supports selector(html:active-view-transition-type(type)){html:active-view-transition-type(qwik-navigation){:root{view-transition-name:none}}}@supports not selector(html:active-view-transition-type(type)){:root{view-transition-name:none}}}</style></head><body :><main :="q1_2"><h1 :>SSG Snapshot Fixture</h1><p : id="tags">routeLoader$ | useResource$ | useSignal</p><section : id="loader-json">{&quot;heading&quot;:&quot;SSG Snapshot Fixture&quot;,&quot;stats&quot;:[2,3,5,8],&quot;tags&quot;:[&quot;routeLoader$&quot;,&quot;useResource$&quot;,&quot;useSignal&quot;],&quot;profile&quot;:{&quot;name&quot;:&quot;router-state&quot;,&quot;status&quot;:&quot;stable&quot;}}</section><section : id="summary-json">{&quot;total&quot;:18,&quot;selectedCount&quot;:2,&quot;clicks&quot;:2}</section><section :="q1_0" id="resource-json">{&quot;headline&quot;:&quot;router-state:stable&quot;,&quot;selected&quot;:[&quot;alpha&quot;,&quot;beta&quot;],&quot;weighted&quot;:[2,6,15,32],&quot;summary&quot;:{&quot;total&quot;:18,&quot;selectedCount&quot;:2,&quot;clicks&quot;:2}}</section></main><script q-d:qcinit="q-xxxxxxxx.js#_run#1|q-xxxxxxxx.js#_run#2" q-d:qinit="q-xxxxxxxx.js#_run#3" q-d:qrouterpopstate="q-xxxxxxxx.js#_run#4" q:p="5" :="Fn_1"></script>[state omitted]
<!DOCTYPE html><html lang="en" q:render="static-ssr" q:route="/" q:container="paused" q:runtime="2" q:base="/build/" q:locale="" q:manifest-hash="MANIFEST_HASH" q:instance="[instance]" :><head :><link rel="modulepreload" href="/build/q-xxxxxxxx.js" :><script async type="module" src="/build/q-xxxxxxxx.js"></script><link rel="modulepreload" href="/build/q-xxxxxxxx.js" :><link rel="preload" href="/assets/xxxxxxxx-bundle-graph.json" as="fetch" crossorigin="anonymous" :><script type="module" async crossorigin="anonymous">let b=fetch("/assets/xxxxxxxx-bundle-graph.json");import("/build/q-xxxxxxxx.js").then(({l})=>l("/build/",b));</script><link rel="modulepreload" href="/build/q-xxxxxxxx.js" :><meta : charset="utf-8"><meta : name="viewport" content="width=device-width, initial-scale=1.0"><link : rel="canonical" href="https://snapshot.qwik.dev/"><title :="r5_0">Qwik Router SSG Snapshot</title><meta name="description" content="Deterministic SSG snapshot fixture" :="r5_1"></head><body :><main :="q1_2"><h1 :>SSG Snapshot Fixture</h1><p : id="tags">routeLoader$ | useResource$ | useSignal</p><section : id="loader-json">{&quot;heading&quot;:&quot;SSG Snapshot Fixture&quot;,&quot;stats&quot;:[2,3,5,8],&quot;tags&quot;:[&quot;routeLoader$&quot;,&quot;useResource$&quot;,&quot;useSignal&quot;],&quot;profile&quot;:{&quot;name&quot;:&quot;router-state&quot;,&quot;status&quot;:&quot;stable&quot;}}</section><section : id="summary-json">{&quot;total&quot;:18,&quot;selectedCount&quot;:2,&quot;clicks&quot;:2}</section><section :="q1_0" id="resource-json">{&quot;headline&quot;:&quot;router-state:stable&quot;,&quot;selected&quot;:[&quot;alpha&quot;,&quot;beta&quot;],&quot;weighted&quot;:[2,6,15,32],&quot;summary&quot;:{&quot;total&quot;:18,&quot;selectedCount&quot;:2,&quot;clicks&quot;:2}}</section></main><script q-d:qcinit="q-xxxxxxxx.js#_run#1|q-xxxxxxxx.js#_run#2" q-d:qinit="q-xxxxxxxx.js#_run#3" q-d:qrouterpopstate="q-xxxxxxxx.js#_run#4" q:p="5" :="Fn_1"></script>[state omitted]
[vnode map omitted]
<script type="module" async="true" q:type="preload">["q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js"].map((l,e)=>{e=document.createElement('link');e.rel='modulepreload';e.href="/build/"+l;document.head.appendChild(e)});window.addEventListener('load',f=>{f=_=>import("/build/q-xxxxxxxx.js").then(({p})=>p(["q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js","q-xxxxxxxx.js"]));try{requestIdleCallback(f,{timeout:2000})}catch(e){setTimeout(f,200)}})</script><script q:func="qwik/json">document["qFuncs_xxxxxx"]=[()=>{((w,h)=>{if(!w._qcs){w._qcs=!0;const s=h.state?._qRouterScroll;if(s){h.scrollRestoration="manual";w.scrollTo(s.x,s.y);}document.dispatchEvent(new Event("qcinit"));}})(window,history);},(p0)=>p0.value.heading,(p0)=>p0.url.href]</script><script>(window._qwikEv||(window._qwikEv=[])).push("d:qcinit","d:qinit","d:qrouterpopstate",0,"[instance]")</script></body></html>
19 changes: 12 additions & 7 deletions e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@
Constant ':'
]
]
8 {number} 25
8 {number} 24
9 Array [
{string} "ji2uyl-0"
RootRef [omitted]
Store [
Object [
Expand All @@ -40,7 +39,7 @@
QRL "[omitted]"
Set [
EffectSubscriptionNoData [
VNode "12AAA"
VNode "11AAA"
Constant ':'
]
EffectSubscriptionNoData [
Expand All @@ -67,7 +66,7 @@
RootRef [omitted]
]
{number} 3
VNode "14A"
VNode "13A"
EffectSubscriptionNoData [
RootRef [omitted]
Constant '.'
Expand Down Expand Up @@ -164,7 +163,7 @@
Set [
RootRef [omitted]
EffectSubscriptionNoData [
VNode "12A"
VNode "11A"
Constant ':'
]
]
Expand Down Expand Up @@ -246,7 +245,7 @@
Task [
QRL "[omitted]"
{number} 10
{number} 22
{number} 21
RootRef [omitted]
]
Constant ':'
Expand All @@ -259,7 +258,7 @@
Task [
QRL "[omitted]"
{number} 2
{number} 23
{number} 22
RootRef [omitted]
]
Constant ':'
Expand All @@ -285,6 +284,12 @@
{number} 1
{number} 1
{number} 1
Task [
QRL "[omitted]"
{number} 10
{number} 20
RootRef [omitted]
]
...
]
10 Array [
Expand Down
7 changes: 5 additions & 2 deletions e2e/qwik-e2e/apps/qwikrouter-test/src/root.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { component$ } from '@qwik.dev/core';
import { component$, useServerData } from '@qwik.dev/core';
import { RouterOutlet, useQwikRouter } from '@qwik.dev/router';
import { SomeProvider } from './components/provider/provider';
import { RouterHead } from './components/router-head/router-head';
import { LogConsole } from '../../../log-console';
import './global.css';

export default component$(function Root() {
useQwikRouter();
// Opt into view transitions via the initial URL so e2e can exercise both paths.
const url = useServerData<string>('url');
const viewTransition = !!url && new URL(url).searchParams.has('viewtransition');
useQwikRouter({ viewTransition });

return (
<SomeProvider>
Expand Down
29 changes: 29 additions & 0 deletions e2e/qwik-e2e/tests/qwikrouter/view-transition.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';

const VIEW_TRANSITION_STYLE = 'style#qwik-view-transition';

test.describe('view transitions', () => {
test.use({ javaScriptEnabled: true });

test('injects the stylesheet lazily on SPA nav when enabled', async ({ page }) => {
await page.goto('/qwikrouter-test/?viewtransition=1');
const style = page.locator(VIEW_TRANSITION_STYLE);

// Opt-in apps still ship nothing at load; the CSS arrives only when a transition runs.
await expect(style).toHaveCount(0);

await page.locator('[data-test-link="docs-home"]').click();
await expect(page).toHaveURL(/\/qwikrouter-test\/docs\/$/);

await expect(style).toHaveCount(1);
});

test('never injects the stylesheet when disabled (default)', async ({ page }) => {
await page.goto('/qwikrouter-test/');

await page.locator('[data-test-link="docs-home"]').click();
await expect(page).toHaveURL(/\/qwikrouter-test\/docs\/$/);

await expect(page.locator(VIEW_TRANSITION_STYLE)).toHaveCount(0);
});
});
2 changes: 1 addition & 1 deletion packages/docs/src/routes/api/qwik-router/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@
}
],
"kind": "Interface",
"content": "```typescript\nexport interface QwikRouterProps \n```\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nviewTransition?\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_ Enable the ViewTransition API\n\nDefault: `true`\n\n\n</td></tr>\n</tbody></table>",
"content": "```typescript\nexport interface QwikRouterProps \n```\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nviewTransition?\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_ Enable the ViewTransition API on SPA navigation. Opt-in: set to `true` to enable.\n\nDefault: `false`\n\n\n</td></tr>\n</tbody></table>",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/qwik-router-component.tsx",
"mdFile": "router.qwikrouterprops.md"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/routes/api/qwik-router/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2047,9 +2047,9 @@ boolean

</td><td>

_(Optional)_ Enable the ViewTransition API
_(Optional)_ Enable the ViewTransition API on SPA navigation. Opt-in: set to `true` to enable.

Default: `true`
Default: `false`

</td></tr>
</tbody></table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@ import { Note } from '~/components/note/note';

# View Transition API

By default Qwik will start a view transition when SPA navigation. We can run animation either with CSS or WAAPI.
View transitions on SPA navigation are opt-in. Enable them by passing `viewTransition` to `QwikRouterProvider`, then run animation with either CSS or WAAPI.

```tsx
// src/root.tsx
export default component$(() => {
return (
<QwikRouterProvider viewTransition={true}>{/* ... */}</QwikRouterProvider>
);
});
```

## CSS

Expand Down
5 changes: 3 additions & 2 deletions packages/qwik-router/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,9 @@ Tracks `contentInternal`, `navContext`, and `actionDataSignal`. Client-only
the navigation commit below.
4. **Scroll setup:** finds scroller element, sets up `__q_scroll_restore__` callback.
5. **SPA init:** calls `initializeSPA(goto, scrollEl)` (one-time setup).
6. **View transition:** calls `startViewTransition({ update: navigate, types })`.
Inside the update callback: `clientNavigate(...)` pushes/replaces history,
6. **View transition (opt-in):** when `viewTransition` is enabled, calls
`startViewTransition({ update: navigate, types })`; otherwise `navigate()` runs
directly. Inside the update callback: `clientNavigate(...)` pushes/replaces history,
`contentInternal.trigger()` fires subscribers (rendering new content),
`_waitUntilRendered(container)` waits for the render cycle.
7. **Post-transition:** sets `q:route` attribute, saves scroll state, enables
Expand Down
15 changes: 8 additions & 7 deletions packages/qwik-router/src/runtime/src/qwik-router-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import {
useServerData,
useSignal,
useStore,
useStyles$,
useTask$,
type QRL,
} from '@qwik.dev/core';
Expand Down Expand Up @@ -77,7 +76,6 @@ import {
} from './contexts';
import { createDocumentHead, resolveHead } from './head';
import { refreshLinkPrefetchObserver } from './link-prefetch';
import transitionCss from './qwik-view-transition.css?inline';
import { loadRoute } from './routing';
import {
callRestoreScrollOnDocument,
Expand Down Expand Up @@ -118,7 +116,11 @@ import type {
import { submitAction } from './use-endpoint';
import { useQwikRouterEnv } from './use-functions';
import { isSameOrigin, isSamePath, toPath, toUrl } from './utils';
import { startViewTransition, type ViewTransition } from './view-transition';
import {
shouldStartViewTransition,
startViewTransition,
type ViewTransition,
} from './view-transition';

declare const window: ClientSPAWindow;

Expand All @@ -134,9 +136,9 @@ export const QWIK_ROUTER_SCROLLER = '_qRouterScroller';
/** @public */
export interface QwikRouterProps {
/**
* Enable the ViewTransition API
* Enable the ViewTransition API on SPA navigation. Opt-in: set to `true` to enable.
*
* Default: `true`
* Default: `false`
*
* @see https://github.com/WICG/view-transitions/blob/main/explainer.md
* @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
Expand Down Expand Up @@ -189,7 +191,6 @@ export const useQwikRouter = (props?: QwikRouterProps) => {
'useQwikRouter can only run during SSR on the server. If you are seeing this, it means you are re-rendering the root of your application. Fix that or use the <QwikRouterProvider> component around the root of your application.'
);
}
useStyles$(transitionCss);
const env = useQwikRouterEnv();
if (!env?.params) {
throw new Error(
Expand Down Expand Up @@ -828,7 +829,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => {
};

const _waitNextPage = () => {
if (props?.viewTransition === false || !('startViewTransition' in document)) {
if (!shouldStartViewTransition(props?.viewTransition)) {
return navigate().then(() => undefined as ViewTransition | undefined);
}
const { ready, transition } = startViewTransition({
Expand Down
20 changes: 20 additions & 0 deletions packages/qwik-router/src/runtime/src/view-transition.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import transitionCss from './qwik-view-transition.css?inline';

// This types are missing in current typescript version: 5.4.5

interface StartViewTransitionOptions {
Expand Down Expand Up @@ -25,11 +27,29 @@ export interface ViewTransition {
types?: Set<string>;
}

/** View transitions are opt-in and require browser support. */
export const shouldStartViewTransition = (viewTransition: boolean | undefined): boolean =>
viewTransition === true && 'startViewTransition' in document;

const VIEW_TRANSITION_STYLE_ID = 'qwik-view-transition';

/** Inject the view-transition stylesheet once, only when a transition actually runs. */
export const ensureViewTransitionStyles = (): void => {
if (document.getElementById(VIEW_TRANSITION_STYLE_ID)) {
return;
}
const style = document.createElement('style');
style.id = VIEW_TRANSITION_STYLE_ID;
style.textContent = transitionCss;
document.head.appendChild(style);
};

export const startViewTransition = (params: {
types: string[];
update: () => Promise<void>;
}): { ready: Promise<void>; transition?: ViewTransition } => {
if ('startViewTransition' in document) {
ensureViewTransitionStyles();
let transition: ViewTransition;
try {
// Typed transition starts with Chrome 125 & Safari 18
Expand Down
53 changes: 53 additions & 0 deletions packages/qwik-router/src/runtime/src/view-transition.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ensureViewTransitionStyles, shouldStartViewTransition } from './view-transition';

describe('shouldStartViewTransition', () => {
afterEach(() => vi.unstubAllGlobals());

const withViewTransitionSupport = (supported: boolean) => {
vi.stubGlobal('document', supported ? { startViewTransition: () => {} } : {});
};

it('is off by default when the prop is omitted', () => {
withViewTransitionSupport(true);
expect(shouldStartViewTransition(undefined)).toBe(false);
});

it('is off when explicitly disabled', () => {
withViewTransitionSupport(true);
expect(shouldStartViewTransition(false)).toBe(false);
});

it('is on when opted in and the browser supports it', () => {
withViewTransitionSupport(true);
expect(shouldStartViewTransition(true)).toBe(true);
});

it('is off when opted in but the browser lacks support', () => {
withViewTransitionSupport(false);
expect(shouldStartViewTransition(true)).toBe(false);
});
});

describe('ensureViewTransitionStyles', () => {
afterEach(() => vi.unstubAllGlobals());

const fakeDocument = () => {
const appended: Array<{ id: string; textContent: string }> = [];
return {
appended,
getElementById: (id: string) => appended.find((el) => el.id === id) ?? null,
createElement: () => ({ id: '', textContent: '' }),
head: { appendChild: (el: { id: string; textContent: string }) => appended.push(el) },
};
};

it('injects the view-transition stylesheet exactly once', () => {
const doc = fakeDocument();
vi.stubGlobal('document', doc);
ensureViewTransitionStyles();
ensureViewTransitionStyles();
expect(doc.appended).toHaveLength(1);
expect(doc.appended[0].id).toBe('qwik-view-transition');
});
});
Loading