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
5 changes: 5 additions & 0 deletions .changeset/true-teeth-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lynx-js/motion': patch
---

Fix an issue that motion/mini will accidentally imports full version, causing compiling error
129 changes: 129 additions & 0 deletions packages/motion/__tests__/dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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, []);
});
});
142 changes: 142 additions & 0 deletions packages/motion/__tests__/mini-polyfill.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <view />;
};

render(<App />, {
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);
});
});
3 changes: 2 additions & 1 deletion packages/motion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
},
"license": "Apache-2.0",
"sideEffects": [
"./dist/polyfill/shim.js"
"./dist/polyfill/shim.js",
"./dist/mini/polyfill.js"
],
"type": "module",
"exports": {
Expand Down
35 changes: 2 additions & 33 deletions packages/motion/src/hooks/useMotionValueRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, MV>(
value: T,
make: (v: T) => MV,
): MainThreadRef<MV> {
// @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<MV> = useMainThreadRef<MV>();

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<unknown>,
]);
}
}, []);

return motionValueRef;
}
export { useMotionValueRefCore };

/**
* @experimental useMotionValue, but in MainThreadRef format, highly experimental, subject to change
Expand Down
38 changes: 38 additions & 0 deletions packages/motion/src/hooks/useMotionValueRefCore.ts
Original file line number Diff line number Diff line change
@@ -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<T, MV>(
value: T,
make: (v: T) => MV,
): MainThreadRef<MV> {
// @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<MV> = useMainThreadRef<MV>();

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<unknown>,
]);
}
}, []);

return motionValueRef;
}
4 changes: 2 additions & 2 deletions packages/motion/src/mini/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading