feat: drive signs from a shared animation clock

This commit is contained in:
2026-09-02 14:14:18 +03:00
parent 24afda8710
commit f2dd3f85dd
9 changed files with 615 additions and 265 deletions

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
import { createSSRApp, defineComponent, h, nextTick } from 'vue';
import { createSSRApp, defineComponent, h, nextTick, shallowRef } from 'vue';
import { renderToString } from '@vue/server-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createScrollDriveController } from '@/journey/scroll-drive-controller';
@@ -170,7 +170,7 @@ describe('useScrollDrive', () => {
const app = createSSRApp(defineComponent({
setup() {
const drive = useScrollDrive();
return () => h('output', drive.value.driveSpeed.toString());
return () => h('output', drive.snapshot.value.driveSpeed.toString());
},
}));
@@ -207,13 +207,14 @@ describe('useScrollDrive', () => {
const documentAdd = vi.spyOn(document, 'addEventListener');
const viewportAdd = vi.spyOn(visualViewport, 'addEventListener');
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({
setup() {
return { drive: useScrollDrive() };
binding = useScrollDrive();
return () => h('output', binding.snapshot.value.routeProgress.toString());
},
template: '<output>{{ drive.routeProgress }}</output>',
}));
const drive = () => wrapper.vm.drive;
const drive = () => binding.snapshot.value;
expect(drive()).toMatchObject({
scrollY: 250,
@@ -306,4 +307,117 @@ describe('useScrollDrive', () => {
document.dispatchEvent(new Event('visibilitychange'));
expect(callbacks).toHaveLength(0);
});
it('keeps the shared frame bus alive only while a subscriber requests frames', async () => {
const { useScrollDrive } = await import('@/composables/useScrollDrive');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 1000 },
clientHeight: { configurable: true, value: 500 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
const id = nextFrameId++;
callbacks.set(id, callback);
return id;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
vi.spyOn(performance, 'now').mockReturnValue(100);
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({
setup() {
binding = useScrollDrive();
return () => h('output', binding.snapshot.value.driveSpeed.toString());
},
}));
let subscriberFrames = 0;
const unsubscribe = binding.subscribeFrame((_now, frameSnapshot) => {
expect(frameSnapshot).toEqual(binding.snapshot.value);
subscriberFrames += 1;
return subscriberFrames < 3;
});
binding.requestFrame();
for (const now of [116, 132, 148]) {
expect(callbacks).toHaveLength(1);
const requested = [...callbacks.entries()][0]!;
callbacks.delete(requested[0]);
requested[1](now);
await nextTick();
}
expect(subscriberFrames).toBe(3);
expect(callbacks).toHaveLength(0);
unsubscribe();
binding.requestFrame();
expect(callbacks).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
it('owns the only raw window scroll listener used by the complete journey binding', async () => {
const { useJourneySections } = await import('@/composables/useJourneySections');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 2000 },
clientHeight: { configurable: true, value: 800 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
const id = nextFrameId++;
callbacks.set(id, callback);
return id;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
vi.spyOn(performance, 'now').mockReturnValue(0);
const add = vi.spyOn(window, 'addEventListener');
const ids = ['start', 'profile', 'contact'] as const;
const navigation = shallowRef({
epoch: 0,
source: 'passive-scroll' as const,
destinationId: null,
});
const motionAllowed = shallowRef(true);
const wrapper = mount(defineComponent({
setup() {
useJourneySections(ids, navigation, motionAllowed);
return () => h('main', ids.map((id) => h('section', { id }, [
h('h2', { id: `${id}-heading` }, id),
])));
},
}), { attachTo: document.body });
expect(add.mock.calls.filter(([type]) => type === 'scroll')).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
});