From f7c049734ecb8ee089088bf23f60194197d5c110 Mon Sep 17 00:00:00 2001 From: f0rdream <14049186+f0rdream@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:02:09 +0800 Subject: [PATCH] fix: Refactor `useMotionValueRefCore` into a dedicated file, introduce a `queueMicroask` polyfill for the `mini` package, and add dependency tests for the `mini` bundle. --- .changeset/true-teeth-cry.md | 5 + .../motion/__tests__/dependencies.test.ts | 129 ++++++++++++++++ .../motion/__tests__/mini-polyfill.test.tsx | 142 ++++++++++++++++++ packages/motion/package.json | 3 +- .../motion/src/hooks/useMotionValueRef.ts | 35 +---- .../motion/src/hooks/useMotionValueRefCore.ts | 38 +++++ packages/motion/src/mini/index.ts | 4 +- packages/motion/src/mini/polyfill.ts | 24 +++ 8 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 .changeset/true-teeth-cry.md create mode 100644 packages/motion/__tests__/dependencies.test.ts create mode 100644 packages/motion/__tests__/mini-polyfill.test.tsx create mode 100644 packages/motion/src/hooks/useMotionValueRefCore.ts create mode 100644 packages/motion/src/mini/polyfill.ts diff --git a/.changeset/true-teeth-cry.md b/.changeset/true-teeth-cry.md new file mode 100644 index 0000000000..c47bbea817 --- /dev/null +++ b/.changeset/true-teeth-cry.md @@ -0,0 +1,5 @@ +--- +'@lynx-js/motion': patch +--- + +Fix an issue that motion/mini will accidentally imports full version, causing compiling error diff --git a/packages/motion/__tests__/dependencies.test.ts b/packages/motion/__tests__/dependencies.test.ts new file mode 100644 index 0000000000..0cc18c514f --- /dev/null +++ b/packages/motion/__tests__/dependencies.test.ts @@ -0,0 +1,129 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'vitest'; +import ts from 'typescript'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const rootDir = path.resolve(__dirname, '../src'); +const entryPoint = path.join(rootDir, 'mini/index.ts'); +const forbiddenFile = path.join(rootDir, 'animation/index.ts'); + +describe('Dependency Check', () => { + it('mini should not depend on animation/index.ts or shared runtime', () => { + const visited = new Set(); + + function check(file: string, pathStack: string[]) { + if (visited.has(file)) { + return; + } + visited.add(file); + + // Resolve file path + let filePath = file; + if (!fs.existsSync(filePath)) { + const extensions = ['.ts', '.js']; + for (const ext of extensions) { + if (fs.existsSync(file + ext)) { + filePath = file + ext; + break; + } + } + } + + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + filePath = path.join(filePath, 'index.ts'); + } + + if (!fs.existsSync(filePath)) { + // console.warn(`File not found: ${file} (dep of ${pathStack[pathStack.length-1]})`); + return; + } + + if (file === forbiddenFile) { + throw new Error( + `Forbidden dependency found: ${ + [...pathStack, file].map((p) => path.relative(rootDir, p)).join( + ' -> ', + ) + }`, + ); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + + // AST Parsing + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + ); + + // Recursive node visitor + function visit(node: ts.Node) { + // Check for forbidden syntax: import ... with { runtime: 'shared' } + if (ts.isImportDeclaration(node)) { + if ( + node.attributes?.elements?.some(el => + el.name.text === 'runtime' + && ts.isStringLiteral(el.value) + && el.value.text === 'shared' + ) + ) { + throw new Error( + `Forbidden syntax "runtime: 'shared'" found in ${ + path.relative(rootDir, filePath) + }\nTrace: ${ + [...pathStack, file].map((p) => path.relative(rootDir, p)).join( + ' -> ', + ) + }`, + ); + } + } + + // Collect imports + let importPath: string | null = null; + + if (ts.isImportDeclaration(node)) { + if (ts.isStringLiteral(node.moduleSpecifier)) { + importPath = node.moduleSpecifier.text; + } + } else if (ts.isExportDeclaration(node)) { + if ( + node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) + ) { + importPath = node.moduleSpecifier.text; + } + } else if ( + ts.isCallExpression(node) + && node.expression.kind === ts.SyntaxKind.ImportKeyword + ) { + // Dynamic import('...') + const arg = node.arguments[0]; + if (arg && ts.isStringLiteral(arg)) { + importPath = arg.text; + } + } + + if (importPath && importPath.startsWith('.')) { + const resolvedPath = path.resolve(path.dirname(filePath), importPath); + const cleanPath = resolvedPath.replace(/\.js$/, ''); + check(cleanPath, [...pathStack, filePath]); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + } + + check(entryPoint, []); + }); +}); diff --git a/packages/motion/__tests__/mini-polyfill.test.tsx b/packages/motion/__tests__/mini-polyfill.test.tsx new file mode 100644 index 0000000000..280666bde5 --- /dev/null +++ b/packages/motion/__tests__/mini-polyfill.test.tsx @@ -0,0 +1,142 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { runOnMainThread, useEffect, useMainThreadRef } from '@lynx-js/react'; +import { act, render } from '@lynx-js/react/testing-library'; +import { animate, createMotionValue } from '../src/mini/index.js'; + +describe('Mini Polyfill Independence', () => { + test('should work in main thread environment', async () => { + let result = null; + + const App = () => { + useEffect(() => { + runOnMainThread(() => { + 'main thread'; + try { + const mv = createMotionValue(0); + + // Should not throw + if (mv.get() !== 0) throw new Error('Initial value wrong'); + + // Animate + animate(mv, 100, { + duration: 0.1, + onComplete: () => { + 'main thread'; + // This will run async + }, + }); + + // If we got here without crashing (e.g. from missing globals), success-ish + return 'success'; + } catch (e) { + return (e as Error).message; + } + })().then(res => { + result = res; + }); + }, []); + return ; + }; + + render(, { + enableMainThread: true, + enableBackgroundThread: true, + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + expect(result).toBe('success'); + }); +}); + +describe('Polyfill Unit Logic', () => { + let originalQueueMicrotask: typeof queueMicrotask; + // biome-ignore lint/suspicious/noExplicitAny: mock globals + let originalLynx: any; + + beforeEach(() => { + vi.resetModules(); + originalQueueMicrotask = globalThis.queueMicrotask; + originalLynx = globalThis.lynx; + }); + + afterEach(() => { + globalThis.queueMicrotask = originalQueueMicrotask; + (globalThis as any).lynx = originalLynx; + vi.restoreAllMocks(); + }); + + test('should use existing queueMicrotask if available', async () => { + const mockQM = vi.fn(); + globalThis.queueMicrotask = mockQM; + + await import('../src/mini/polyfill.js'); + + expect(globalThis.queueMicrotask).toBe(mockQM); + }); + + test('should use lynx.queueMicrotask if global is missing', async () => { + delete (globalThis as any).queueMicrotask; + const mockQM = vi.fn(); + (globalThis as any).lynx = { queueMicrotask: mockQM }; + + await import('../src/mini/polyfill.js'); + + expect(globalThis.queueMicrotask).toBe(mockQM); + }); + + test('should fallback to promise-based polyfill if both missing', async () => { + delete (globalThis as any).queueMicrotask; + delete (globalThis as any).lynx; + + await import('../src/mini/polyfill.js'); + + expect(globalThis.queueMicrotask).toBeDefined(); + expect(globalThis.queueMicrotask).not.toBe(originalQueueMicrotask); + + const fn = vi.fn(); + globalThis.queueMicrotask(fn); + + expect(fn).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(fn).toHaveBeenCalled(); + }); + + test('fallback should rethrow errors via setTimeout', async () => { + delete (globalThis as any).queueMicrotask; + delete (globalThis as any).lynx; + + await import('../src/mini/polyfill.js'); + + const error = new Error('test error'); + const fn = vi.fn().mockImplementation(() => { + throw error; + }); + // Prevent actual throw from crashing test + // biome-ignore lint/suspicious/noExplicitAny: mock + const setTimeoutSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation((cb) => { + try { + if (typeof cb === 'function') (cb as Function)(); + } catch { + // ignore + } + return 0 as any; + }); + + globalThis.queueMicrotask(fn); + + // Wait enough ticks + await Promise.resolve(); + await Promise.resolve(); + + expect(fn).toHaveBeenCalled(); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0); + }); +}); diff --git a/packages/motion/package.json b/packages/motion/package.json index fa04c4309a..942a7396ad 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -16,7 +16,8 @@ }, "license": "Apache-2.0", "sideEffects": [ - "./dist/polyfill/shim.js" + "./dist/polyfill/shim.js", + "./dist/mini/polyfill.js" ], "type": "module", "exports": { diff --git a/packages/motion/src/hooks/useMotionValueRef.ts b/packages/motion/src/hooks/useMotionValueRef.ts index 7110841eef..20ce62277f 100644 --- a/packages/motion/src/hooks/useMotionValueRef.ts +++ b/packages/motion/src/hooks/useMotionValueRef.ts @@ -3,43 +3,12 @@ // LICENSE file in the root directory of this source tree. import type { MotionValue } from 'motion-dom'; -import { runOnMainThread, useMainThreadRef, useMemo } from '@lynx-js/react'; import type { MainThreadRef } from '@lynx-js/react'; -import { runWorkletCtx } from '@lynx-js/react/worklet-runtime/bindings'; -import type { - Worklet, - WorkletRef, -} from '@lynx-js/react/worklet-runtime/bindings'; +import { useMotionValueRefCore } from './useMotionValueRefCore.js'; import { motionValue } from '../animation/index.js'; -export function useMotionValueRefCore( - value: T, - make: (v: T) => MV, -): MainThreadRef { - // @ts-expect-error - useMainThreadRef doesn't require initial value but TypeScript expects it - // This is safe because we initialize it in the useMemo below before any usage - const motionValueRef: MainThreadRef = useMainThreadRef(); - - useMemo(() => { - function setMotionValue(value: T) { - 'main thread'; - if (!motionValueRef.current) { - motionValueRef.current = make(value); - } - } - if (__BACKGROUND__) { - void runOnMainThread(setMotionValue)(value); - } else { - // Type assertion needed to bridge between worklet runtime and motion value types - runWorkletCtx(setMotionValue as unknown as Worklet, [ - value as WorkletRef, - ]); - } - }, []); - - return motionValueRef; -} +export { useMotionValueRefCore }; /** * @experimental useMotionValue, but in MainThreadRef format, highly experimental, subject to change diff --git a/packages/motion/src/hooks/useMotionValueRefCore.ts b/packages/motion/src/hooks/useMotionValueRefCore.ts new file mode 100644 index 0000000000..254ba56a39 --- /dev/null +++ b/packages/motion/src/hooks/useMotionValueRefCore.ts @@ -0,0 +1,38 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { runOnMainThread, useMainThreadRef, useMemo } from '@lynx-js/react'; +import type { MainThreadRef } from '@lynx-js/react'; +import { runWorkletCtx } from '@lynx-js/react/worklet-runtime/bindings'; +import type { + Worklet, + WorkletRef, +} from '@lynx-js/react/worklet-runtime/bindings'; + +export function useMotionValueRefCore( + value: T, + make: (v: T) => MV, +): MainThreadRef { + // @ts-expect-error - useMainThreadRef doesn't require initial value but TypeScript expects it + // This is safe because we initialize it in the useMemo below before any usage + const motionValueRef: MainThreadRef = useMainThreadRef(); + + useMemo(() => { + function setMotionValue(value: T) { + 'main thread'; + if (!motionValueRef.current) { + motionValueRef.current = make(value); + } + } + if (__BACKGROUND__) { + void runOnMainThread(setMotionValue)(value); + } else { + // Type assertion needed to bridge between worklet runtime and motion value types + runWorkletCtx(setMotionValue as unknown as Worklet, [ + value as WorkletRef, + ]); + } + }, []); + + return motionValueRef; +} diff --git a/packages/motion/src/mini/index.ts b/packages/motion/src/mini/index.ts index 6d52cfbda7..7364508ef8 100644 --- a/packages/motion/src/mini/index.ts +++ b/packages/motion/src/mini/index.ts @@ -3,10 +3,10 @@ // LICENSE file in the root directory of this source tree. import type { MainThreadRef } from '@lynx-js/react'; -import '../polyfill/shim.js'; +import './polyfill.js'; import { useMotionValueRefEvent as useMotionValueRefEvent_ } from '../hooks/useMotionEvent.js'; -import { useMotionValueRefCore } from '../hooks/useMotionValueRef.js'; +import { useMotionValueRefCore } from '../hooks/useMotionValueRefCore.js'; import { createMotionValue } from './core/MotionValue.js'; import type { MotionValue, diff --git a/packages/motion/src/mini/polyfill.ts b/packages/motion/src/mini/polyfill.ts new file mode 100644 index 0000000000..19ad356d1c --- /dev/null +++ b/packages/motion/src/mini/polyfill.ts @@ -0,0 +1,24 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +function shimQueueMicroTask() { + if (!globalThis.queueMicrotask) { + // Guard against undefined lynx global before accessing lynx.queueMicrotask + if (typeof lynx !== 'undefined' && lynx.queueMicrotask) { + // eslint-disable-next-line @typescript-eslint/unbound-method + globalThis.queueMicrotask = lynx.queueMicrotask; + } else { + const resolved = globalThis.Promise.resolve(); + globalThis.queueMicrotask = (fn) => { + // Schedule as a microtask, and surface exceptions like queueMicrotask would. + resolved.then(fn).catch((err) => { + setTimeout(() => { + throw err; + }, 0); + }); + }; + } + } +} + +shimQueueMicroTask();