forked from akai/readest
fix(reader): gate route View Transitions on API support (READEST-9) (#4989)
* fix(reader): gate route View Transitions on the API, turns on groups (READEST-9) Reverts #4949, which opened books through the plain router to dodge the "Transition was aborted because of timeout in DOM update" TimeoutError (Sentry READEST-9). Rather than carve the transition out of one flow, gate it at the router: useAppRouter routes through the View Transition router only where the engine has the View Transitions API, and every into-reader path (including the reverted ones) goes back through useAppRouter. The base View Transitions API and nested view-transition groups reach very different browsers, so they become two separate appService capability flags, each backed by a probe in utils/viewTransition: * supportsViewTransitionsAPI (document.startViewTransition): the baseline a route crossfade needs, landing on Chrome 111+, Safari 18+, recent WebView. Gates the router. * supportsViewTransitionGroup (view-transition-group: nearest, Chrome/WebView 140+): the far narrower target the paginator's layered turns require. Gates the turn-style options and the captured-turn fallback. Both flags fold in the Linux WebKitGTK carve-out because it crashes on the snapshot, matching the supportsCanvasContext2DFilter precedent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tts): enlarge the Now Playing bar and scale its controls responsively Grow the collapsed bar to h-14 with a 10x10 cover and symmetric px-2 padding, drive the play/pause and close icons through useResponsiveSize instead of fixed pixel sizes, and cut the bottom safe-area contribution to a third so the bar sits closer to the screen edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAppRouter } from '@/hooks/useAppRouter';
|
||||
|
||||
const transitionRouter = { push: vi.fn(), replace: vi.fn(), back: vi.fn() };
|
||||
const plainRouter = { push: vi.fn(), replace: vi.fn(), back: vi.fn() };
|
||||
|
||||
vi.mock('next-view-transitions', () => ({
|
||||
useTransitionRouter: () => transitionRouter,
|
||||
}));
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => plainRouter,
|
||||
}));
|
||||
|
||||
const useEnvMock = vi.fn();
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => useEnvMock(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
useEnvMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useAppRouter', () => {
|
||||
it('routes through the View Transition router when the engine has the API', () => {
|
||||
useEnvMock.mockReturnValue({ appService: { supportsViewTransitionsAPI: true } });
|
||||
const { result } = renderHook(() => useAppRouter());
|
||||
expect(result.current).toBe(transitionRouter);
|
||||
});
|
||||
|
||||
it('falls back to the plain router when the engine lacks the View Transitions API', () => {
|
||||
useEnvMock.mockReturnValue({ appService: { supportsViewTransitionsAPI: false } });
|
||||
const { result } = renderHook(() => useAppRouter());
|
||||
expect(result.current).toBe(plainRouter);
|
||||
});
|
||||
|
||||
it('falls back to the plain router before the app service is ready', () => {
|
||||
useEnvMock.mockReturnValue({ appService: null });
|
||||
const { result } = renderHook(() => useAppRouter());
|
||||
expect(result.current).toBe(plainRouter);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
applyPageTurnAttributes,
|
||||
getCapturedTurnStyle,
|
||||
supportsViewTransitionTurns,
|
||||
} from '@/app/reader/hooks/useCapturedTurn';
|
||||
import { applyPageTurnAttributes, getCapturedTurnStyle } from '@/app/reader/hooks/useCapturedTurn';
|
||||
import type { FoliateView } from '@/types/view';
|
||||
import type { ViewSettings } from '@/types/book';
|
||||
|
||||
@@ -50,23 +46,6 @@ afterEach(() => {
|
||||
delete (document as unknown as VTDocument).startViewTransition;
|
||||
});
|
||||
|
||||
describe('supportsViewTransitionTurns', () => {
|
||||
it('reports no support without startViewTransition', () => {
|
||||
stubEngine({ startViewTransition: false, nestedGroups: true });
|
||||
expect(supportsViewTransitionTurns()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports no support when nested view-transition groups are missing (iOS 18 WebKit)', () => {
|
||||
stubEngine({ startViewTransition: true, nestedGroups: false });
|
||||
expect(supportsViewTransitionTurns()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports support on engines with nested view-transition groups', () => {
|
||||
stubEngine({ startViewTransition: true, nestedGroups: true });
|
||||
expect(supportsViewTransitionTurns()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCapturedTurnStyle', () => {
|
||||
it('captures the slide on Tauri when the engine cannot layer View Transitions', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { detectViewTransitionGroup, detectViewTransitionsAPI } from '@/utils/viewTransition';
|
||||
|
||||
// The DOM lib types startViewTransition as always present; go through a loose
|
||||
// shape so the stub can also remove it.
|
||||
type VTDocument = { startViewTransition?: () => void };
|
||||
|
||||
const stubEngine = ({
|
||||
startViewTransition,
|
||||
nestedGroups,
|
||||
}: {
|
||||
startViewTransition: boolean;
|
||||
nestedGroups: boolean;
|
||||
}) => {
|
||||
const doc = document as unknown as VTDocument;
|
||||
if (startViewTransition) doc.startViewTransition = () => {};
|
||||
else delete doc.startViewTransition;
|
||||
vi.stubGlobal('CSS', {
|
||||
supports: (property: string, value: string) =>
|
||||
nestedGroups && property === 'view-transition-group' && value === 'nearest',
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete (document as unknown as VTDocument).startViewTransition;
|
||||
});
|
||||
|
||||
describe('detectViewTransitionsAPI', () => {
|
||||
it('is true on any engine with document.startViewTransition, even without nested groups', () => {
|
||||
stubEngine({ startViewTransition: true, nestedGroups: false });
|
||||
expect(detectViewTransitionsAPI()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false without the View Transitions API', () => {
|
||||
stubEngine({ startViewTransition: false, nestedGroups: true });
|
||||
expect(detectViewTransitionsAPI()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectViewTransitionGroup', () => {
|
||||
it('requires nested view-transition groups on top of the API', () => {
|
||||
stubEngine({ startViewTransition: true, nestedGroups: false });
|
||||
expect(detectViewTransitionGroup()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true only when the API and nested groups are both present', () => {
|
||||
stubEngine({ startViewTransition: true, nestedGroups: true });
|
||||
expect(detectViewTransitionGroup()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false without the API even if the group query matches', () => {
|
||||
stubEngine({ startViewTransition: false, nestedGroups: true });
|
||||
expect(detectViewTransitionGroup()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MdClose, MdPauseCircleFilled, MdPlayCircleFilled } from 'react-icons/md';
|
||||
import { ttsSessionManager, TTSSession } from '@/services/tts';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -39,6 +40,8 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [timerLabel, setTimerLabel] = useState('');
|
||||
const size20 = useResponsiveSize(20);
|
||||
const size30 = useResponsiveSize(30);
|
||||
|
||||
useEffect(() => {
|
||||
const onSessionChanged = () => {
|
||||
@@ -126,7 +129,7 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
|
||||
'motion-safe:transition-all motion-safe:duration-200',
|
||||
entered ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
|
||||
)}
|
||||
style={{ paddingBottom: `${(safeAreaInsets?.bottom ?? 0) + 16}px` }}
|
||||
style={{ paddingBottom: `${(safeAreaInsets?.bottom ?? 0) / 3 + 16}px` }}
|
||||
>
|
||||
<div
|
||||
role='button'
|
||||
@@ -138,13 +141,17 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
|
||||
aria-label={`${_('Open Book')}: ${title}`}
|
||||
className={clsx(
|
||||
'not-eink:bg-base-300 eink-bordered flex items-center gap-2 rounded-full shadow-lg',
|
||||
'h-11 max-w-[calc(100vw-1rem)] cursor-pointer ps-2 pe-1',
|
||||
'h-14 max-w-[calc(100vw-1rem)] cursor-pointer px-2',
|
||||
'focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none',
|
||||
)}
|
||||
>
|
||||
{coverImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={coverImageUrl} alt='' className='h-8 w-8 shrink-0 rounded-full object-cover' />
|
||||
<img
|
||||
src={coverImageUrl}
|
||||
alt=''
|
||||
className='h-10 w-10 shrink-0 rounded-full object-cover'
|
||||
/>
|
||||
) : null}
|
||||
<span className='min-w-0 flex-1 truncate text-sm'>{title}</span>
|
||||
{timerLabel && (
|
||||
@@ -159,7 +166,7 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
|
||||
handleToggle();
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <MdPauseCircleFilled size={28} /> : <MdPlayCircleFilled size={28} />}
|
||||
{isPlaying ? <MdPauseCircleFilled size={size30} /> : <MdPlayCircleFilled size={size30} />}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
@@ -170,7 +177,7 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
|
||||
handleStop();
|
||||
}}
|
||||
>
|
||||
<MdClose size={22} />
|
||||
<MdClose size={size20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Book } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAppRouter } from '@/hooks/useAppRouter';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
|
||||
@@ -25,11 +25,7 @@ interface UseOpenBookOptions {
|
||||
*/
|
||||
export const useOpenBook = ({ setLoading, handleBookDownload }: UseOpenBookOptions) => {
|
||||
const _ = useTranslation();
|
||||
// Open the reader with the plain router, NOT the View Transition router:
|
||||
// the reader's initial render is heavy enough to blow the transition's ~4s
|
||||
// DOM-update budget, which aborts with a TimeoutError (Sentry READEST-9).
|
||||
// This matches how every other into-reader path already navigates.
|
||||
const router = useRouter();
|
||||
const router = useAppRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { MdChevronRight } from 'react-icons/md';
|
||||
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService, DeleteAction } from '@/types/system';
|
||||
@@ -163,10 +163,6 @@ const LibraryPageWithSearchParams = () => {
|
||||
|
||||
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
|
||||
const router = useAppRouter();
|
||||
// Opening the reader is a heavy render that overruns the View Transition
|
||||
// DOM-update budget (TimeoutError, Sentry READEST-9), so navigate to it with
|
||||
// the plain router; `router` above keeps transitions for lighter navigation.
|
||||
const readerRouter = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
@@ -580,10 +576,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const bookIds = pendingNavigationBookIds;
|
||||
setPendingNavigationBookIds(null);
|
||||
if (bookIds.length > 0) {
|
||||
navigateToReader(readerRouter, bookIds);
|
||||
navigateToReader(router, bookIds);
|
||||
}
|
||||
}
|
||||
}, [pendingNavigationBookIds, appService, readerRouter]);
|
||||
}, [pendingNavigationBookIds, appService, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { captureWebviewRegion } from '@/utils/bridge';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { detectViewTransitionGroup } from '@/utils/viewTransition';
|
||||
import { CapturedPageTurn, CapturedTurnStyle } from '../utils/capturedTurn';
|
||||
import { useTouchInterceptor } from './useTouchInterceptor';
|
||||
|
||||
@@ -13,21 +14,6 @@ import { useTouchInterceptor } from './useTouchInterceptor';
|
||||
// take over where the engine supports them, push everywhere else.
|
||||
let captureBroken = false;
|
||||
|
||||
/**
|
||||
* Whether the engine runs the paginator's layered View Transition turns
|
||||
* reliably. iOS 18 WebKit ships `document.startViewTransition` but crashes
|
||||
* the WebContent process when a turn transition snapshots the reader, so
|
||||
* the presence of the API is not enough; nested view-transition groups
|
||||
* (Chrome/WebView 140+) mark the mature engines where the layered turns
|
||||
* are known to work.
|
||||
*/
|
||||
export const supportsViewTransitionTurns = (): boolean =>
|
||||
typeof document !== 'undefined' &&
|
||||
'startViewTransition' in document &&
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('view-transition-group', 'nearest');
|
||||
|
||||
/**
|
||||
* The turn style the captured-page pipeline should drive for this view, or
|
||||
* null when the paginator's own turns apply. The pipeline needs a native
|
||||
@@ -46,7 +32,7 @@ export const getCapturedTurnStyle = (
|
||||
return null;
|
||||
}
|
||||
if (viewSettings.pageTurnStyle === 'curl') return 'curl';
|
||||
if (viewSettings.pageTurnStyle === 'slide' && !supportsViewTransitionTurns()) return 'slide';
|
||||
if (viewSettings.pageTurnStyle === 'slide' && !detectViewTransitionGroup()) return 'slide';
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -66,7 +52,7 @@ export const applyPageTurnAttributes = (
|
||||
) => {
|
||||
const captured = getCapturedTurnStyle(viewSettings, isFixedLayout);
|
||||
const style = viewSettings.pageTurnStyle;
|
||||
if (style && style !== 'push' && !captured && supportsViewTransitionTurns()) {
|
||||
if (style && style !== 'push' && !captured && detectViewTransitionGroup()) {
|
||||
view.renderer.setAttribute('turn-style', style);
|
||||
} else {
|
||||
view.renderer.removeAttribute('turn-style');
|
||||
|
||||
@@ -12,10 +12,7 @@ import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
|
||||
import { PageTurnStyle } from '@/types/book';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import { annotationToolQuickActions } from '@/app/reader/components/annotator/AnnotationTools';
|
||||
import {
|
||||
applyPageTurnAttributes,
|
||||
supportsViewTransitionTurns,
|
||||
} from '@/app/reader/hooks/useCapturedTurn';
|
||||
import { applyPageTurnAttributes } from '@/app/reader/hooks/useCapturedTurn';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import {
|
||||
BoxedList,
|
||||
@@ -84,7 +81,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
// the VT turns, so on the web they only get Push (readest#555).
|
||||
const turnStyleOptions = [
|
||||
{ value: 'push', label: _('Push') },
|
||||
...(supportsViewTransitionTurns() || isTauriAppPlatform()
|
||||
...(appService?.supportsViewTransitionGroup || isTauriAppPlatform()
|
||||
? [
|
||||
{ value: 'slide', label: _('Slide') },
|
||||
{ value: 'curl', label: _('Page Curl') },
|
||||
|
||||
@@ -7,6 +7,11 @@ export const useAppRouter = () => {
|
||||
const transitionRouter = useTransitionRouter();
|
||||
const plainRouter = useRouter();
|
||||
|
||||
// View Transitions API crashes WebKitGTK 4.1 on Linux
|
||||
return appService?.isLinuxApp ? plainRouter : transitionRouter;
|
||||
// A route transition is a plain full-page crossfade, so it only needs the
|
||||
// base View Transitions API - not the nested view-transition groups the
|
||||
// paginator turns require. Route through the transition router wherever the
|
||||
// API is usable (appService folds in the Linux WebKitGTK carve-out); engines
|
||||
// without it navigate plainly, sidestepping the DOM-update-budget TimeoutError
|
||||
// seen on unsupported webviews (Sentry READEST-9).
|
||||
return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter;
|
||||
};
|
||||
|
||||
@@ -65,6 +65,8 @@ export abstract class BaseAppService implements AppService {
|
||||
canCustomizeRootDir = false;
|
||||
canReadExternalDir = false;
|
||||
supportsCanvasContext2DFilter = true;
|
||||
supportsViewTransitionsAPI = false;
|
||||
supportsViewTransitionGroup = false;
|
||||
distChannel = 'readest' as DistChannel;
|
||||
storefrontRegionCode: string | null = null;
|
||||
isOnlineCatalogsAccessible = true;
|
||||
|
||||
@@ -40,6 +40,7 @@ import { getDirPath, getFilename } from '@/utils/path';
|
||||
import { NativeFile, RemoteFile } from '@/utils/file';
|
||||
import { copyURIToPath, getStorefrontRegionCode, saveImageToGallery } from '@/utils/bridge';
|
||||
import { copyFiles } from '@/utils/files';
|
||||
import { detectViewTransitionGroup, detectViewTransitionsAPI } from '@/utils/viewTransition';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { DatabaseOpts, DatabaseService } from '@/types/database';
|
||||
@@ -578,6 +579,11 @@ export class NativeAppService extends BaseAppService {
|
||||
override canReadExternalDir = DIST_CHANNEL !== 'appstore' && DIST_CHANNEL !== 'playstore';
|
||||
override supportsCanvasContext2DFilter =
|
||||
OS_TYPE !== 'ios' && OS_TYPE !== 'macos' && OS_TYPE !== 'linux';
|
||||
// WebKitGTK on Linux crashes when a View Transition snapshots the window,
|
||||
// so both capabilities are unavailable there regardless of what the engine
|
||||
// reports; every other webview is gated on the real feature probe.
|
||||
override supportsViewTransitionsAPI = OS_TYPE !== 'linux' && detectViewTransitionsAPI();
|
||||
override supportsViewTransitionGroup = OS_TYPE !== 'linux' && detectViewTransitionGroup();
|
||||
override distChannel = DIST_CHANNEL;
|
||||
override storefrontRegionCode: string | null = null;
|
||||
override isOnlineCatalogsAccessible = true;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SchemaType } from '@/services/database/migrate';
|
||||
import { getOSPlatform, isValidURL } from '@/utils/misc';
|
||||
import { isSafariBrowser } from '@/utils/ua';
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
import { detectViewTransitionGroup, detectViewTransitionsAPI } from '@/utils/viewTransition';
|
||||
import { isPWA } from './environment';
|
||||
import { BaseAppService } from './appService';
|
||||
import {
|
||||
@@ -289,6 +290,8 @@ export class WebAppService extends BaseAppService {
|
||||
override isMobile = ['android', 'ios'].includes(getOSPlatform());
|
||||
override appPlatform = 'web' as AppPlatform;
|
||||
override supportsCanvasContext2DFilter = !isSafariBrowser();
|
||||
override supportsViewTransitionsAPI = detectViewTransitionsAPI();
|
||||
override supportsViewTransitionGroup = detectViewTransitionGroup();
|
||||
override hasSafeAreaInset = isPWA();
|
||||
|
||||
override async init() {
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface AppService {
|
||||
canCustomizeRootDir: boolean;
|
||||
canReadExternalDir: boolean;
|
||||
supportsCanvasContext2DFilter: boolean;
|
||||
supportsViewTransitionsAPI: boolean;
|
||||
supportsViewTransitionGroup: boolean;
|
||||
distChannel: DistChannel;
|
||||
storefrontRegionCode: string | null;
|
||||
isOnlineCatalogsAccessible: boolean;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Whether the engine implements the View Transitions API at all
|
||||
* (`document.startViewTransition`). This is the baseline a simple route
|
||||
* crossfade needs, and it lands broadly: Chrome 111+, Edge, Safari 18+, and
|
||||
* recent Android WebView.
|
||||
*/
|
||||
export const detectViewTransitionsAPI = (): boolean =>
|
||||
typeof document !== 'undefined' && 'startViewTransition' in document;
|
||||
|
||||
/**
|
||||
* Whether the engine also supports nested view-transition groups
|
||||
* (`view-transition-group: nearest`, Chrome/WebView 140+) - a far narrower
|
||||
* target than the base API. This is what the paginator's layered turns
|
||||
* require: iOS 18 WebKit ships `startViewTransition` but crashes the
|
||||
* WebContent process on layered snapshots, so the group query marks the
|
||||
* mature engines where the layered turns are known to work.
|
||||
*/
|
||||
export const detectViewTransitionGroup = (): boolean =>
|
||||
detectViewTransitionsAPI() &&
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('view-transition-group', 'nearest');
|
||||
Reference in New Issue
Block a user