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
7 changes: 7 additions & 0 deletions changelog.d/fix-motion-minified-duration-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Fixed the blank home page hero on GitHub Pages. The production CSS minifier
rewrites motion duration tokens such as `100ms` as `.1s`, and
`motionDuration()` (`src/hooks/useMotion.ts`) rejected the leading dot, so the
hero animation threw after hiding its targets and left the headline, subtitle
and call-to-action at opacity 0 (since #189). The parser now accepts the
minified form, and `useMotion` fails open: if an animation setup throws, it
reverts the GSAP context so the content is shown without animation.
46 changes: 45 additions & 1 deletion src/hooks/useMotion.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, render } from '@testing-library/react';
import { useCallback, useRef } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useMotion } from './useMotion';
import { motionDuration, useMotion } from './useMotion';

const mocks = vi.hoisted(() => ({
context: vi.fn(),
Expand Down Expand Up @@ -71,4 +71,48 @@ describe('useMotion', () => {
expect(cleanup).toHaveBeenCalledOnce();
expect(mocks.context.mock.results[0]?.value.revert).toHaveBeenCalledOnce();
});

it('reverts the context when setup throws, so hidden targets are shown again', async () => {
mocks.noPreference = true;
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const setup = vi.fn(() => {
throw new Error('Invalid motion duration token: --motion-duration-fast');
});
render(<MotionProbe setup={setup} />);

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});

expect(setup).toHaveBeenCalledOnce();
expect(mocks.context.mock.results[0]?.value.revert).toHaveBeenCalledOnce();
expect(consoleError).toHaveBeenCalledWith(
'Motion setup failed; showing content without animation',
expect.any(Error),
);
consoleError.mockRestore();
});
});

describe('motionDuration', () => {
const root = document.documentElement;
const token = '--motion-duration-test' as const;

it.each([
['100ms', 0.1],
['.1s', 0.1],
['.5s', 0.5],
['0.3s', 0.3],
['1.4s', 1.4],
['50ms', 0.05],
['1s', 1],
])('parses %s (source and minified forms) as %s seconds', (value, seconds) => {
root.style.setProperty(token, value);
expect(motionDuration(token)).toBeCloseTo(seconds);
});

it.each(['', 'fast', '1.s', '-1s', '100'])('rejects %j', (value) => {
root.style.setProperty(token, value);
expect(() => motionDuration(token)).toThrow('Invalid motion duration token');
});
});
22 changes: 20 additions & 2 deletions src/hooks/useMotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,29 @@ export function useMotion<T extends Element>(
.then(({ gsap }) => {
if (cancelled) return;
const media = gsap.matchMedia();
let setupError: unknown;
const context = gsap.context(() => {
media.add('(prefers-reduced-motion: no-preference)', () => setup(gsap, scope));
media.add('(prefers-reduced-motion: no-preference)', () => {
try {
return setup(gsap, scope);
} catch (error) {
setupError = error;
}
});
}, scope);
revert = () => {
media.revert();
context.revert();
};
// Fail open: a setup usually hides its targets (gsap.set opacity 0)
// before animating them in, so a setup that throws part-way would leave
// the content invisible. Reverting the context undoes those sets.
if (setupError !== undefined) {
console.error('Motion setup failed; showing content without animation', setupError);
revert();
revert = undefined;
return;
}
if (cancelled) revert();
})
.catch((error: unknown) => {
Expand All @@ -43,7 +59,9 @@ export function motionDuration(
token: `--motion-duration-${string}`,
): number {
const value = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(value);
// Accepts a leading dot: the production CSS minifier rewrites `100ms` as
// `.1s`, and rejecting that left the home page hero invisible on Pages.
const match = /^(\d*\.?\d+)(ms|s)$/.exec(value);
if (!match) throw new Error(`Invalid motion duration token: ${token}`);
const duration = Number(match[1]);
return match[2] === 'ms' ? duration / 1000 : duration;
Expand Down
Loading