Skip to content
Open
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
42 changes: 42 additions & 0 deletions .changeset/better-pigs-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@lynx-js/react': patch
---

Add support for `MainThreadValue` to enable Main Thread Persistent Data other than `MainThreadRef`, to make library developers able to create their own main thread values.

```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language hint to the fenced code block.

This satisfies MD040 and keeps markdown linting clean.

🛠️ Minimal fix
-```
+```ts

As per coding guidelines, ...

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

7-7: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In @.changeset/better-pigs-run.md at line 7, The fenced code block in the
changeset uses a plain triple-backtick with no language hint; update that fence
to include a language tag (e.g., change ``` to ```ts) so the block is annotated
(satisfies MD040). Locate the triple-backtick fence in
.changeset/better-pigs-run.md and add the appropriate language identifier after
the opening backticks.

// Library code
import { MainThreadValue } from '@lynx-js/react'

class MotionValue extends MainThreadValue {
static type = '@example/motion-value';

constructor(initValue: T) {
super(initValue, MotionValue.type);
}

get value() {
return this.getValueOnMainThread();
}

set value(v) {
return this.setValueOnMainThread(v);
}
}

MainThreadValue.register(MotionValue, MotionValue.type);

export function useMotionValue<T>(initValue: T): MotionValue<T> {
return useMemo(() => new MotionValue(initValue), []);
}

// User code
function App() {
const opacity = useMotionValue(1);

function handleTap() {
'main thread'
opacity.value = 123
}
}
```
58 changes: 55 additions & 3 deletions examples/react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,80 @@
import { useCallback, useEffect, useState } from '@lynx-js/react';
import {
runOnMainThread,
useCallback,
useEffect,
useState,
} from '@lynx-js/react';

import './App.css';
import arrow from './assets/arrow.png';
import lynxLogo from './assets/lynx-logo.png';
import reactLynxLogo from './assets/react-logo.png';
import { useMotionValue } from './MotionValue.js';

import './App.css';

export function App() {
const [alterLogo, setAlterLogo] = useState(false);

// NEW: MotionValue POC (uses .value) - demonstrates extensible main thread values!
const opacity = useMotionValue(1);
const tapCount = useMotionValue(0);

useEffect(() => {
console.info('Hello, ReactLynx');

let unsubscribeHandle: (() => void) | undefined;

runOnMainThread(() => {
'main thread';

// Demo: Subscribe to value changes (main thread only)
const unsubscribe = opacity.subscribe((newValue) => {
console.info('Opacity changed to:', newValue);
});

// Demo: Subscribe to value changes (main thread only)
const unsubscribeTapCount = tapCount.subscribe((newValue) => {
console.info('TapCount changed to:', newValue);
});

return () => {
unsubscribe();
unsubscribeTapCount();
};
})().then((res) => {
unsubscribeHandle = res as () => void;
}).catch(_err => {
// omitted
});

return () => {
void runOnMainThread(unsubscribeHandle!)();
};
Comment on lines 22 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the actual code in App.tsx at the specified lines
cat -n examples/react/src/App.tsx | sed -n '1,70p'

Repository: lynx-family/lynx-stack

Length of output: 2332


🏁 Script executed:

# Find where runOnMainThread is defined
fd -t f '*.ts' -o '*.tsx' -o '*.js' | xargs rg -l 'runOnMainThread' | head -20

Repository: lynx-family/lynx-stack

Length of output: 1352


🏁 Script executed:

# Get the definition of runOnMainThread
rg -A 10 'export.*runOnMainThread|function runOnMainThread' --type ts --type tsx

Repository: lynx-family/lynx-stack

Length of output: 93


🏁 Script executed:

# Check the type definitions or interface for runOnMainThread
rg -B 2 -A 5 'runOnMainThread' examples/react/src/App.tsx

Repository: lynx-family/lynx-stack

Length of output: 592


🏁 Script executed:

# Find the runOnMainThread implementation
cat packages/react/runtime/src/worklet/call/runOnMainThread.ts

Repository: lynx-family/lynx-stack

Length of output: 2409


🏁 Script executed:

# Check the test files for runOnMainThread to understand expected behavior
cat packages/react/runtime/__test__/worklet/runOnMainThread.test.jsx

Repository: lynx-family/lynx-stack

Length of output: 8942


🏁 Script executed:

# Also check the basic test
cat packages/react/worklet-runtime/__test__/runOnMainThread.test.js

Repository: lynx-family/lynx-stack

Length of output: 1316


🏁 Script executed:

# Check the web platform test to understand what runOnMainThread does
cat packages/web-platform/web-core-wasm-e2e/tests/reactlynx/basic-mts-run-on-main-thread/index.jsx | head -80

Repository: lynx-family/lynx-stack

Length of output: 915


🏁 Script executed:

# Look for similar subscription patterns with cleanup
rg -B 3 -A 5 '\.subscribe\(' packages/motion/src/ --type ts --type tsx

Repository: lynx-family/lynx-stack

Length of output: 93


🏁 Script executed:

# Search for other useEffect + runOnMainThread patterns
rg -B 2 -A 8 'useEffect.*runOnMainThread|runOnMainThread.*\.then' --type ts --type tsx

Repository: lynx-family/lynx-stack

Length of output: 93


🏁 Script executed:

# Check if there are other examples in examples directory
find examples -name "*.tsx" -o -name "*.ts" | xargs rg -l "useEffect" | head -5

Repository: lynx-family/lynx-stack

Length of output: 237


🏁 Script executed:

# Search for subscription patterns without type specification
rg '\.subscribe\(' packages/motion/src/ -A 5

Repository: lynx-family/lynx-stack

Length of output: 126


🏁 Script executed:

# Check other example apps for comparison
for file in examples/tailwindcss/src/App.tsx examples/react-lazy-bundle/src/App.tsx examples/react-externals/src/App.tsx; do
  echo "=== $file ==="; 
  cat -n "$file" | head -60;
done

Repository: lynx-family/lynx-stack

Length of output: 4904


🏁 Script executed:

# Look for async patterns with runOnMainThread
rg 'async.*runOnMainThread|runOnMainThread.*async' -B 2 -A 5

Repository: lynx-family/lynx-stack

Length of output: 3317


Guard main-thread subscription cleanup against race conditions.
The component can unmount before the .then() promise resolves, leaving unsubscribeHandle undefined. The non-null assertion at line 51 masks this type safety issue and cleanup will fail silently. Use async/await with a cancellation guard and explicit error handling instead.

🔧 Proposed fix
   useEffect(() => {
     console.info('Hello, ReactLynx');

-    let unsubscribeHandle: (() => void) | undefined;
-
-    runOnMainThread(() => {
-      'main thread';
-
-      // Demo: Subscribe to value changes (main thread only)
-      const unsubscribe = opacity.subscribe((newValue) => {
-        console.info('Opacity changed to:', newValue);
-      });
-
-      // Demo: Subscribe to value changes (main thread only)
-      const unsubscribeTapCount = tapCount.subscribe((newValue) => {
-        console.info('TapCount changed to:', newValue);
-      });
-
-      return () => {
-        unsubscribe();
-        unsubscribeTapCount();
-      };
-    })().then((res) => {
-      unsubscribeHandle = res as () => void;
-    }).catch(_err => {
-      // omitted
-    });
-
-    return () => {
-      void runOnMainThread(unsubscribeHandle!)();
-    };
+    let unsubscribeHandle: (() => void) | undefined;
+    let cancelled = false;
+
+    const init = async () => {
+      try {
+        const res = await runOnMainThread(() => {
+          'main thread';
+
+          // Demo: Subscribe to value changes (main thread only)
+          const unsubscribe = opacity.subscribe((newValue) => {
+            console.info('Opacity changed to:', newValue);
+          });
+
+          // Demo: Subscribe to value changes (main thread only)
+          const unsubscribeTapCount = tapCount.subscribe((newValue) => {
+            console.info('TapCount changed to:', newValue);
+          });
+
+          return () => {
+            unsubscribe();
+            unsubscribeTapCount();
+          };
+        })();
+
+        if (typeof res !== 'function') {
+          return;
+        }
+
+        if (cancelled) {
+          await runOnMainThread(res)();
+          return;
+        }
+
+        unsubscribeHandle = res;
+      } catch (err) {
+        console.error('Main thread subscription failed', err);
+      }
+    };
+
+    void init();
+
+    return () => {
+      cancelled = true;
+      if (unsubscribeHandle) {
+        void runOnMainThread(unsubscribeHandle)().catch((err) => {
+          console.error('Main thread cleanup failed', err);
+        });
+      }
+    };
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
console.info('Hello, ReactLynx');
let unsubscribeHandle: (() => void) | undefined;
runOnMainThread(() => {
'main thread';
// Demo: Subscribe to value changes (main thread only)
const unsubscribe = opacity.subscribe((newValue) => {
console.info('Opacity changed to:', newValue);
});
// Demo: Subscribe to value changes (main thread only)
const unsubscribeTapCount = tapCount.subscribe((newValue) => {
console.info('TapCount changed to:', newValue);
});
return () => {
unsubscribe();
unsubscribeTapCount();
};
})().then((res) => {
unsubscribeHandle = res as () => void;
}).catch(_err => {
// omitted
});
return () => {
void runOnMainThread(unsubscribeHandle!)();
};
useEffect(() => {
console.info('Hello, ReactLynx');
let unsubscribeHandle: (() => void) | undefined;
let cancelled = false;
const init = async () => {
try {
const res = await runOnMainThread(() => {
'main thread';
// Demo: Subscribe to value changes (main thread only)
const unsubscribe = opacity.subscribe((newValue) => {
console.info('Opacity changed to:', newValue);
});
// Demo: Subscribe to value changes (main thread only)
const unsubscribeTapCount = tapCount.subscribe((newValue) => {
console.info('TapCount changed to:', newValue);
});
return () => {
unsubscribe();
unsubscribeTapCount();
};
})();
if (typeof res !== 'function') {
return;
}
if (cancelled) {
await runOnMainThread(res)();
return;
}
unsubscribeHandle = res;
} catch (err) {
console.error('Main thread subscription failed', err);
}
};
void init();
return () => {
cancelled = true;
if (unsubscribeHandle) {
void runOnMainThread(unsubscribeHandle)().catch((err) => {
console.error('Main thread cleanup failed', err);
});
}
};
}, []);
🤖 Prompt for AI Agents
In `@examples/react/src/App.tsx` around lines 22 - 52, The cleanup race occurs
because unsubscribeHandle may be undefined when the component unmounts; change
the runOnMainThread call to an async IIFE that awaits the result and uses a
local "cancelled" flag (or keeps the pending Promise) so cleanup can safely
no-op if the subscription setup hasn't completed: in useEffect, replace the
.then/.catch pattern with an async function that calls runOnMainThread(),
assigns the returned unsubscribe function to a local variable (e.g.,
unsubscribeHandle) only if not cancelled, and on return set cancelled=true and,
if unsubscribeHandle is set, call it; reference the existing useEffect,
runOnMainThread, unsubscribeHandle, opacity.subscribe, tapCount.subscribe, and
the unsubscribe/unsubscribeTapCount variables to locate where to implement this
guard and explicit error handling.

}, []);

Check warning on line 53 in examples/react/src/App.tsx

View workflow job for this annotation

GitHub Actions / eslint / check

React Hook useEffect has missing dependencies: 'opacity' and 'tapCount'. Either include them or remove the dependency array

const onTap = useCallback(() => {
'background-only';
setAlterLogo(prevAlterLogo => !prevAlterLogo);
}, []);

// Demo: MotionValue in tap handler
const onLogoTap = () => {
'main thread';
tapCount.value += 1;
// Animate opacity based on tap count
opacity.value = 0.5 + (tapCount.value % 2) * 0.5;
};

return (
<view>
<view className='Background' />
<view className='App'>
<view className='Banner'>
<view className='Logo' bindtap={onTap}>
<view
className='Logo'
bindtap={onTap}
main-thread:bindtap={onLogoTap}
>
{alterLogo
? <image src={reactLynxLogo} className='Logo--react' />
: <image src={lynxLogo} className='Logo--lynx' />}
Expand Down
63 changes: 63 additions & 0 deletions examples/react/src/MotionValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* MotionValue POC using the MainThreadValue base class.
*
* This demonstrates how third-party packages can create their own
* main thread value types by extending MainThreadValue.
*/
import { MainThreadValue, useMemo } from '@lynx-js/react';

/**
* A simple motion value that can be accessed on the main thread.
* Uses `.value` instead of `.current` to demonstrate the new extensibility.
*/
export class MotionValue<T> extends MainThreadValue<T> {
private _subscribers: Set<(value: T) => void> = new Set();

constructor(initValue: T, type = '@example/motion-value') {
super(initValue, type);
}

/**
* Get the current value. Only works on main thread.
*/
get value(): T {
try {
return this.getValueOnMainThread();
} catch (_e) {
// Allow access on background thread for capture (returns undefined/garbage, which is ignored)
return this._initValue;
}
}

/**
* Set the current value. Only works on main thread.
* Notifies all subscribers when value changes.
*/
set value(v: T) {
this.setValueOnMainThread(v);
// Notify subscribers
this._subscribers.forEach(cb => cb(v));
}

/**
* Subscribe to value changes.
* @param callback - Called with the new value when it changes
* @returns Unsubscribe function
*/
subscribe(callback: (value: T) => void): () => void {
this._subscribers.add(callback);
return () => {
this._subscribers.delete(callback);
};
}
}

// Register for runtime hydration
MainThreadValue.register(MotionValue, '@example/motion-value');

/**
* Hook to create a MotionValue that persists across renders.
*/
export function useMotionValue<T>(initValue: T): MotionValue<T> {
return useMemo(() => new MotionValue(initValue), []);
}
1 change: 1 addition & 0 deletions packages/react/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,4 @@ export {
};

export * from './lynx-api.js';
export { workletCapture } from './worklet/capture.js';
1 change: 1 addition & 0 deletions packages/react/runtime/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,4 @@ export { transformToWorklet } from './worklet/call/transformToWorklet.js';
export { registerWorkletOnBackground } from './worklet/hmr.js';

export { loadWorkletRuntime } from '@lynx-js/react/worklet-runtime/bindings';
export { workletCapture } from './worklet/capture.js';
1 change: 1 addition & 0 deletions packages/react/runtime/src/lynx-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,4 @@ export { useLynxGlobalEventListener } from './hooks/useLynxGlobalEventListener.j
export { runOnBackground } from './worklet/call/runOnBackground.js';
export { runOnMainThread } from './worklet/call/runOnMainThread.js';
export { MainThreadRef, useMainThreadRef } from './worklet/ref/workletRef.js';
export { MainThreadValue } from './worklet/ref/mainThreadValue.js';
25 changes: 25 additions & 0 deletions packages/react/runtime/src/worklet/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2024 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.

/**
* Worklet capture helper function.
* Called by SWC plugin to preserve MainThreadValue objects during closure extraction.
* @internal
*/
// biome-ignore lint/suspicious/noExplicitAny: Called by generated code with dynamic args
export function workletCapture(
obj: unknown,
...args: unknown[]
): unknown {
// If it's a Main Thread Value (e.g. MotionValue), return it (ID will be serialized)
if (obj && typeof obj === 'object' && (obj as { __MT_PERSIST__?: boolean }).__MT_PERSIST__) {
return obj;
}
// Otherwise, reconstruct a lightweight object with only the captured properties
const result: Record<string, unknown> = {};
for (let i = 0; i < args.length; i += 2) {
result[args[i] as string] = args[i + 1];
}
return result;
}
Loading
Loading