diff --git a/playground/src/pages/basic/ready/LoopCallbackWatcher.vue b/playground/src/pages/basic/ready/LoopCallbackWatcher.vue new file mode 100644 index 000000000..2fc8281bd --- /dev/null +++ b/playground/src/pages/basic/ready/LoopCallbackWatcher.vue @@ -0,0 +1,62 @@ + diff --git a/playground/src/pages/basic/ready/OnTresReadyWatcher.vue b/playground/src/pages/basic/ready/OnTresReadyWatcher.vue new file mode 100644 index 000000000..120856b6e --- /dev/null +++ b/playground/src/pages/basic/ready/OnTresReadyWatcher.vue @@ -0,0 +1,69 @@ + diff --git a/playground/src/pages/basic/ready/index.vue b/playground/src/pages/basic/ready/index.vue new file mode 100644 index 000000000..5a4c96f0f --- /dev/null +++ b/playground/src/pages/basic/ready/index.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/playground/src/router/routes/basic.ts b/playground/src/router/routes/basic.ts index 4c89e1585..1a3ef62f2 100644 --- a/playground/src/router/routes/basic.ts +++ b/playground/src/router/routes/basic.ts @@ -44,4 +44,9 @@ export const basicRoutes = [ name: 'Pierced Props', component: () => import('../../pages/basic/PiercedProps.vue'), }, + { + path: '/basic/ready', + name: '@ready', + component: () => import('../../pages/basic/ready/index.vue'), + }, ] diff --git a/src/components/TresCanvas.vue b/src/components/TresCanvas.vue index eb765fdd8..1c88adf77 100644 --- a/src/components/TresCanvas.vue +++ b/src/components/TresCanvas.vue @@ -89,6 +89,7 @@ const emit = defineEmits([ 'pointer-out', 'pointer-missed', 'wheel', + 'ready', ]) const slots = defineSlots<{ diff --git a/src/composables/index.ts b/src/composables/index.ts index 09af31d51..76ac41787 100644 --- a/src/composables/index.ts +++ b/src/composables/index.ts @@ -10,3 +10,4 @@ export * from './usePointerEventHandler' export * from './useTresContextProvider' export * from './useLoop' export * from './useTresEventManager' +export { onTresReady } from './useTresReady' diff --git a/src/composables/useTresContextProvider/index.ts b/src/composables/useTresContextProvider/index.ts index c1839d63d..dc94cc585 100644 --- a/src/composables/useTresContextProvider/index.ts +++ b/src/composables/useTresContextProvider/index.ts @@ -15,6 +15,7 @@ import type { TresEventManager } from '../useTresEventManager' import useSizes, { type SizesType } from '../useSizes' import type { RendererLoop } from '../../core/loop' import { createRenderLoop } from '../../core/loop' +import { useTresReady } from '../useTresReady' export interface InternalState { priority: Ref @@ -209,9 +210,15 @@ export function useTresContextProvider({ } }, 'render') - ctx.loop.start() + const { on: onTresReady, cancel: cancelTresReady } = useTresReady(ctx)! + + onTresReady(() => { + emit('ready', ctx) + ctx.loop.start() + }) onUnmounted(() => { + cancelTresReady() ctx.loop.stop() }) diff --git a/src/composables/useTresReady/createReadyEventHook/createReadyHook.test.ts b/src/composables/useTresReady/createReadyEventHook/createReadyHook.test.ts new file mode 100644 index 000000000..b2417ce8a --- /dev/null +++ b/src/composables/useTresReady/createReadyEventHook/createReadyHook.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createReadyEventHook } from './index' + +describe('createReadyEventHook', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('createReadyEventHook(getIsReady)', () => { + it('calls getIsReady when created', () => { + const getIsReady = vi.fn(() => true) + createReadyEventHook(getIsReady, null) + expect(getIsReady).toBeCalled() + }) + + it('calls getIsReady periodically', () => { + const fn = vi.fn(() => false) + createReadyEventHook(fn, null, 1) + vi.advanceTimersByTime(1000) + expect(fn).toHaveBeenCalledTimes(1000 + 1) + }) + + it('calls getIsReady periodically, but only until `getIsReady()` is truthy', () => { + let i = 0 + const fn0 = () => { + i++ + return i === 5 + } + createReadyEventHook(fn0, null) + vi.advanceTimersByTime(1000) + expect(i).toBe(5) + + i = -1 + const fn1 = () => { + i++ + return i + } + createReadyEventHook(fn1 as any, null) + vi.advanceTimersByTime(1000) + expect(i).toBe(1) + }) + + it('calls getIsReady periodically, but only while not cancelled', () => { + const fn = vi.fn(() => false) + const { cancel } = createReadyEventHook(fn, null, 1) + vi.advanceTimersByTime(99) + cancel() + vi.advanceTimersByTime(1000) + expect(fn).toHaveBeenCalledTimes(100) + }) + }) + + describe('createReadyEventHook(getIsReady, intervalMs)', () => { + it('calls getIsReady at the provided interval', () => { + const fn = vi.fn(() => false) + createReadyEventHook(fn, null, 100) + expect(fn).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(99) + expect(fn).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(1000) + expect(fn).toHaveBeenCalledTimes(10 + 1) + vi.advanceTimersByTime(5000) + expect(fn).toHaveBeenCalledTimes(50 + 10 + 1) + }) + }) + + describe('createReadyEventHook().on', () => { + it('registers a function and calls it once `getIsReady() === true`', () => { + const fn = vi.fn() + const { on } = createReadyEventHook(trueIfCalledNTimes(10), null) + + on(fn) + vi.advanceTimersByTime(10000) + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('calls registered functions with args', () => { + const fn0 = vi.fn() + const fn1 = vi.fn() + const arg0 = { foo: 'bar' } + const arg1 = { baz: 'boo' } + const { on } = createReadyEventHook(() => true, [arg0, arg1]) + + on(fn0) + on(fn1) + + expect(fn0).toHaveBeenCalledWith([{ foo: 'bar' }, { baz: 'boo' }]) + expect(fn1).toHaveBeenCalledWith([{ foo: 'bar' }, { baz: 'boo' }]) + }) + + it('calls a function immediately if `getIsReady() === true`', () => { + const fn = vi.fn() + const { on } = createReadyEventHook(() => true, null) + + on(fn) + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('calls functions with arg immediately if `getIsReady() === true`', () => { + const fn0 = vi.fn() + const fn1 = vi.fn() + const arg = { foo: 'bar' } + const { on } = createReadyEventHook(() => true, arg) + + on(fn0) + on(fn1) + + expect(fn0).toHaveBeenCalledWith({ foo: 'bar' }) + expect(fn1).toHaveBeenCalledWith({ foo: 'bar' }) + }) + + it('can register many functions, one at a time', () => { + const fns = Array.from({ length: 100 }) + .fill(0) + .map(_ => vi.fn()) + + const { on } = createReadyEventHook(trueIfCalledNTimes(10), null) + fns.forEach(fn => on(fn)) + vi.advanceTimersByTime(10000) + + for (const fn of fns) { + expect(fn).toHaveBeenCalledTimes(1) + } + }) + }) + + describe('createReadyEventHook().off(fn)', () => { + it('unregisters a function', () => { + const fns = Array.from({ length: 100 }) + .fill(0) + .map(_ => vi.fn()) + + const { on, off } = createReadyEventHook(trueIfCalledNTimes(10), null) + fns.forEach(fn => on(fn)) + + const offedFns = new Set() + fns.forEach((fn) => { + if (Math.random() < 0.5) { + offedFns.add(fn) + off(fn) + } + }) + vi.advanceTimersByTime(10000) + + fns.forEach((fn) => { + expect(fn).toHaveBeenCalledTimes(offedFns.has(fn) ? 0 : 1) + }) + }) + }) + + describe('createReadyEventHook().on(fn).off()', () => { + it('unregisters a function', () => { + const fns = Array.from({ length: 100 }) + .fill(0) + .map(_ => vi.fn()) + + const { on } = createReadyEventHook(trueIfCalledNTimes(10), null) + + const offedFns = new Set() + fns.forEach((fn) => { + const { off } = on(fn) + if (Math.random() < 0.5) { + offedFns.add(fn) + off() + } + }) + vi.advanceTimersByTime(1000) + + fns.forEach((fn) => { + expect(fn).toHaveBeenCalledTimes(offedFns.has(fn) ? 0 : 1) + }) + }) + }) +}) + +function trueIfCalledNTimes(n: number) { + return () => { + n = Math.max(n - 1, 0) + return n === 0 + } +} diff --git a/src/composables/useTresReady/createReadyEventHook/index.ts b/src/composables/useTresReady/createReadyEventHook/index.ts new file mode 100644 index 000000000..0ee8efef3 --- /dev/null +++ b/src/composables/useTresReady/createReadyEventHook/index.ts @@ -0,0 +1,86 @@ +import type { EventHook, EventHookOn, IsAny } from '@vueuse/core' +import { createEventHook } from '@vueuse/core' + +type Callback = + IsAny extends true + ? (param: any) => void + : [T] extends [void] + ? () => void + : (param: T) => void + +export function createReadyEventHook( + getIsReady: () => boolean, + triggerParams: T, + pollIntervalMs = 100, +): EventHook & { cancel: () => void } { + pollIntervalMs = pollIntervalMs <= 0 ? 100 : pollIntervalMs + const hook = createEventHook() + // NOTE: This hook will likely be long-lived and + // we don't want to interfere with garbage collection + // in the meantime. + // Keep a set of `offFns` and call them after `getIsReady` + // in order to remove them from the `hook`. + const offFns = new Set<() => void>() + let ready = false + let cancelled = false + let timeoutId: ReturnType | null = null + + function doReadyTest() { + if (timeoutId) { + clearTimeout(timeoutId) + } + if (!cancelled && !ready && getIsReady()) { + hook.trigger(triggerParams) + offFns.forEach(offFn => offFn()) + offFns.clear() + ready = true + } + else if (!cancelled && !ready) { + timeoutId = setTimeout(doReadyTest, pollIntervalMs) + } + } + + function cancel() { + cancelled = true + if (timeoutId) { + clearTimeout(timeoutId) + } + } + + if (import.meta.hot) { + import.meta.hot.on('vite:afterUpdate', () => { + ready = false + doReadyTest() + }) + } + + doReadyTest() + + const triggerSingleCallback = (callback: Callback, ...args: [T]) => { + callback(...args) + } + + const onOrCall: EventHookOn = (callback) => { + if (!ready) { + const onFn = hook.on(callback) + + if (!import.meta.hot) { + // NOTE: We must keep callbacks around for HMR. + // But if it doesn't exist, remove callbacks. + offFns.add(onFn.off) + } + return hook.on(callback) + } + else { + triggerSingleCallback(callback, triggerParams) + return { off: () => {} } + } + } + + return { + on: onOrCall, + off: hook.off, + trigger: hook.trigger, + cancel, + } +} diff --git a/src/composables/useTresReady/index.ts b/src/composables/useTresReady/index.ts new file mode 100644 index 000000000..9e239e22d --- /dev/null +++ b/src/composables/useTresReady/index.ts @@ -0,0 +1,53 @@ +import type { TresContext } from '../useTresContextProvider' +import { useTresContext } from '../useTresContextProvider' +import { createReadyEventHook } from './createReadyEventHook' + +const ctxToUseTresReady = new WeakMap< + TresContext, + ReturnType> +>() + +export function useTresReady(ctx?: TresContext) { + ctx = ctx || useTresContext() + if (ctxToUseTresReady.has(ctx)) { + return ctxToUseTresReady.get(ctx)! + } + + const MAX_READY_WAIT_MS = 100 + const start = Date.now() + + // NOTE: Consider Tres to be "ready" if either is true: + // - MAX_READY_WAIT_MS has passed (assume Tres is intentionally degenerate) + // - Tres is not degenerate + // - A renderer exists + // - A DOM element exists + // - The DOM element's height/width is not 0 + const getTresIsReady = () => { + if (Date.now() - start >= MAX_READY_WAIT_MS) { + return true + } + else { + const renderer = ctx.renderer.value + const domElement = renderer?.domElement || { width: 0, height: 0 } + return !!(renderer && domElement.width > 0 && domElement.height > 0) + } + } + + const args = ctx as TresContext + const result = createReadyEventHook(getTresIsReady, args) + ctxToUseTresReady.set(ctx, result) + + return result +} + +export function onTresReady(fn: (ctx: TresContext) => void) { + const ctx = useTresContext() + if (ctx) { + if (ctxToUseTresReady.has(ctx)) { + return ctxToUseTresReady.get(ctx)!.on(fn) + } + else { + return useTresReady(ctx).on(fn) + } + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 6906523a1..0f7550d93 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -20,7 +20,7 @@ export interface TresCatalogue { [name: string]: ConstructorRepresentation } -export type EmitEventName = 'render' | 'click' | 'double-click' | 'context-menu' | 'pointer-move' | 'pointer-up' | 'pointer-down' | 'pointer-enter' | 'pointer-leave' | 'pointer-over' | 'pointer-out' | 'pointer-missed' | 'wheel' +export type EmitEventName = 'render' | 'ready' | 'click' | 'double-click' | 'context-menu' | 'pointer-move' | 'pointer-up' | 'pointer-down' | 'pointer-enter' | 'pointer-leave' | 'pointer-over' | 'pointer-out' | 'pointer-missed' | 'wheel' export type EmitEventFn = (event: EmitEventName, ...args: any[]) => void export type TresCamera = THREE.OrthographicCamera | THREE.PerspectiveCamera