mirror of
https://github.com/tlsnotary/tlsn-extension.git
synced 2026-01-08 20:48:03 -05:00
Add useState hook to plugin-sdk with state persistence, re-rendering, and DevConsole UI enhancements
This commit is contained in:
@@ -122,16 +122,26 @@ const config = {
|
||||
* 5. Return the proof result to the caller via done()
|
||||
*/
|
||||
async function onClick() {
|
||||
// Step 1: Get the intercepted header from the X.com API request
|
||||
// useHeaders() provides access to all intercepted HTTP request headers
|
||||
// We filter for the specific X.com API endpoint we want to prove
|
||||
const [header] = useHeaders(headers => {
|
||||
return headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json'));
|
||||
});
|
||||
// Check if request is already pending
|
||||
const isRequestPending = useState('isRequestPending', false);
|
||||
if (isRequestPending) {
|
||||
return; // Prevent multiple concurrent requests
|
||||
}
|
||||
|
||||
// Step 2: Extract authentication headers from the intercepted request
|
||||
// These headers are required to authenticate with the X.com API
|
||||
const headers = {
|
||||
// Set request pending state to true
|
||||
setState('isRequestPending', true);
|
||||
|
||||
try {
|
||||
// Step 1: Get the intercepted header from the X.com API request
|
||||
// useHeaders() provides access to all intercepted HTTP request headers
|
||||
// We filter for the specific X.com API endpoint we want to prove
|
||||
const [header] = useHeaders(headers => {
|
||||
return headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json'));
|
||||
});
|
||||
|
||||
// Step 2: Extract authentication headers from the intercepted request
|
||||
// These headers are required to authenticate with the X.com API
|
||||
const headers = {
|
||||
// Cookie: Session authentication token
|
||||
'cookie': header.requestHeaders.find(header => header.name === 'Cookie')?.value,
|
||||
|
||||
@@ -235,9 +245,18 @@ async function onClick() {
|
||||
}
|
||||
);
|
||||
|
||||
// Step 4: Complete plugin execution and return the proof result
|
||||
// done() signals that the plugin has finished and passes the result back
|
||||
done(JSON.stringify(resp));
|
||||
// Step 4: Complete plugin execution and return the proof result
|
||||
// done() signals that the plugin has finished and passes the result back
|
||||
done(JSON.stringify(resp));
|
||||
} catch (error) {
|
||||
console.error('Proof generation failed:', error);
|
||||
done({ error: error.message });
|
||||
} finally {
|
||||
// Reset request pending state after 1 second to allow UI feedback
|
||||
setTimeout(() => {
|
||||
setState('isRequestPending', false);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -250,14 +269,28 @@ async function onClick() {
|
||||
* React-like Hooks Used:
|
||||
* - useHeaders(): Subscribes to intercepted HTTP request headers
|
||||
* - useEffect(): Runs side effects when dependencies change
|
||||
* - useState(): Manages component state
|
||||
*
|
||||
* UI Flow:
|
||||
* 1. Check if X.com API request headers have been intercepted
|
||||
* 2. If not intercepted yet: Show "Please login" message
|
||||
* 3. If intercepted: Show "Profile detected" with a "Prove" button
|
||||
* 4. On first render: Open X.com in a new window to trigger login
|
||||
* 5. Can minimize to floating action button and expand back
|
||||
*/
|
||||
function expandUI() {
|
||||
setState('isMinimized', false);
|
||||
}
|
||||
|
||||
function minimizeUI() {
|
||||
setState('isMinimized', true);
|
||||
}
|
||||
|
||||
function main() {
|
||||
// State management
|
||||
const isMinimized = useState('isMinimized', false);
|
||||
const isRequestPending = useState('isRequestPending', false);
|
||||
|
||||
// Subscribe to intercepted headers for the X.com API endpoint
|
||||
// This will reactively update whenever new headers matching the filter arrive
|
||||
const [header] = useHeaders(headers => headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json')));
|
||||
@@ -269,49 +302,177 @@ function main() {
|
||||
openWindow('https://x.com');
|
||||
}, []);
|
||||
|
||||
// If minimized, show floating action button
|
||||
if (isMinimized) {
|
||||
return div({
|
||||
style: {
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#4CAF50',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
|
||||
zIndex: '999999',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
fontSize: '24px',
|
||||
color: 'white',
|
||||
},
|
||||
onclick: 'expandUI',
|
||||
}, ['🔐']);
|
||||
}
|
||||
|
||||
// Loading spinner component
|
||||
const spinner = div({
|
||||
style: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
border: '3px solid #f3f3f3',
|
||||
borderTop: '3px solid #4CAF50',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '10px auto',
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add keyframes for spinner animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }';
|
||||
if (!document.head.querySelector('style[data-plugin-spinner]')) {
|
||||
style.setAttribute('data-plugin-spinner', 'true');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Render the plugin UI overlay
|
||||
// This creates a fixed-position widget in the bottom-right corner
|
||||
return div({
|
||||
style: {
|
||||
position: 'fixed', // Fixed positioning relative to viewport
|
||||
bottom: '0', // Anchor to bottom of screen
|
||||
right: '8px', // 8px from right edge
|
||||
width: '240px', // Fixed width
|
||||
height: '240px', // Fixed height
|
||||
borderRadius: '4px 4px 0 0', // Rounded top corners only
|
||||
backgroundColor: '#b8b8b8', // Light gray background
|
||||
zIndex: '999999', // Ensure it appears above page content
|
||||
fontSize: '16px', // Base font size
|
||||
color: '#0f0f0f', // Dark text color
|
||||
border: '1px solid #e2e2e2', // Light border
|
||||
borderBottom: 'none', // No bottom border (anchored to screen)
|
||||
padding: '8px', // Internal spacing
|
||||
fontFamily: 'sans-serif', // Standard font
|
||||
position: 'fixed',
|
||||
bottom: '0',
|
||||
right: '8px',
|
||||
width: '280px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
|
||||
zIndex: '999999',
|
||||
fontSize: '14px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}, [
|
||||
// Status indicator showing whether profile is detected
|
||||
// Header with minimize button
|
||||
div({
|
||||
style: {
|
||||
fontWeight: 'bold',
|
||||
// Green if header detected, red if not
|
||||
color: header ? 'green' : 'red',
|
||||
},
|
||||
}, [ header ? 'Profile detected!' : 'No profile detected']),
|
||||
|
||||
// Conditional UI based on whether we have intercepted the headers
|
||||
// If header exists: Show "Prove" button that triggers onClick()
|
||||
// If header doesn't exist: Show "Please login" message
|
||||
header
|
||||
? button({
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: 'white',
|
||||
}
|
||||
}, [
|
||||
div({
|
||||
style: {
|
||||
color: 'black',
|
||||
backgroundColor: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
}
|
||||
}, ['X Profile Prover']),
|
||||
button({
|
||||
style: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// The onclick attribute references the onClick function name
|
||||
// When clicked, the onClick() function will be called
|
||||
onclick: 'onClick',
|
||||
}, ['Prove'])
|
||||
: div({ style: {color: 'black'}}, ['Please login to x.com'])
|
||||
onclick: 'minimizeUI',
|
||||
}, ['−'])
|
||||
]),
|
||||
|
||||
// Content area
|
||||
div({
|
||||
style: {
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
}
|
||||
}, [
|
||||
// Status indicator showing whether profile is detected
|
||||
div({
|
||||
style: {
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: header ? '#d4edda' : '#f8d7da',
|
||||
color: header ? '#155724' : '#721c24',
|
||||
border: \`1px solid \$\{header ? '#c3e6cb' : '#f5c6cb'\}\`,
|
||||
fontWeight: '500',
|
||||
},
|
||||
}, [
|
||||
header ? '✓ Profile detected' : '⚠ No profile detected'
|
||||
]),
|
||||
|
||||
// Conditional UI based on whether we have intercepted the headers
|
||||
header ? (
|
||||
isRequestPending ?
|
||||
// Show spinner when request is pending
|
||||
div({
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
}
|
||||
}, [
|
||||
spinner,
|
||||
div({
|
||||
style: {
|
||||
marginTop: '12px',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
}
|
||||
}, ['Generating proof...'])
|
||||
])
|
||||
:
|
||||
// Show prove button when not pending
|
||||
button({
|
||||
style: {
|
||||
width: '100%',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: '15px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
},
|
||||
onclick: 'onClick',
|
||||
}, ['Generate Proof'])
|
||||
) : (
|
||||
// Show login message
|
||||
div({
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #ffeaa7',
|
||||
}
|
||||
}, ['Please login to x.com to continue'])
|
||||
)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -322,11 +483,15 @@ function main() {
|
||||
* All plugins must export an object with these properties:
|
||||
* - main: The reactive UI rendering function
|
||||
* - onClick: Click handler callback for buttons
|
||||
* - expandUI: Handler to expand from minimized state
|
||||
* - minimizeUI: Handler to minimize the UI
|
||||
* - config: Plugin metadata
|
||||
*/
|
||||
export default {
|
||||
main,
|
||||
onClick,
|
||||
expandUI,
|
||||
minimizeUI,
|
||||
config,
|
||||
};
|
||||
`;
|
||||
|
||||
425
packages/plugin-sdk/docs/useState.md
Normal file
425
packages/plugin-sdk/docs/useState.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# useState Hook Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The `useState` hook provides state management capabilities for TLSN plugins, similar to React's useState but adapted for the QuickJS sandbox environment. It allows plugins to maintain state across renders and trigger UI updates when state changes.
|
||||
|
||||
## API Reference
|
||||
|
||||
### useState(key, defaultValue)
|
||||
|
||||
Retrieves the current value of a state variable or initializes it with a default value.
|
||||
|
||||
**Parameters:**
|
||||
- `key` (string): Unique identifier for the state variable
|
||||
- `defaultValue` (any, optional): Initial value if the state doesn't exist
|
||||
|
||||
**Returns:**
|
||||
- The current state value
|
||||
|
||||
### setState(key, value)
|
||||
|
||||
Updates a state variable and triggers a re-render if the value has changed.
|
||||
|
||||
**Parameters:**
|
||||
- `key` (string): Unique identifier for the state variable
|
||||
- `value` (any): New value for the state
|
||||
|
||||
**Returns:**
|
||||
- void
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Counter Example
|
||||
|
||||
```javascript
|
||||
function onClick() {
|
||||
const count = useState('count', 0);
|
||||
setState('count', count + 1);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const count = useState('count', 0);
|
||||
|
||||
return div({}, [
|
||||
div({}, ['Count: ' + count]),
|
||||
button({ onclick: 'onClick' }, ['Increment'])
|
||||
]);
|
||||
}
|
||||
|
||||
export default { main, onClick };
|
||||
```
|
||||
|
||||
### Loading State Example
|
||||
|
||||
```javascript
|
||||
async function onClick() {
|
||||
// Prevent multiple concurrent requests
|
||||
const isLoading = useState('isLoading', false);
|
||||
if (isLoading) return;
|
||||
|
||||
setState('isLoading', true);
|
||||
|
||||
try {
|
||||
// Perform async operation
|
||||
const result = await prove(/* ... */);
|
||||
done(result);
|
||||
} finally {
|
||||
setState('isLoading', false);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const isLoading = useState('isLoading', false);
|
||||
|
||||
if (isLoading) {
|
||||
return div({}, [
|
||||
div({
|
||||
style: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
border: '3px solid #f3f3f3',
|
||||
borderTop: '3px solid #4CAF50',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}
|
||||
}, []),
|
||||
div({}, ['Processing...'])
|
||||
]);
|
||||
}
|
||||
|
||||
return button({ onclick: 'onClick' }, ['Start']);
|
||||
}
|
||||
|
||||
export default { main, onClick };
|
||||
```
|
||||
|
||||
### UI Minimize/Expand Example
|
||||
|
||||
```javascript
|
||||
function main() {
|
||||
const isMinimized = useState('isMinimized', false);
|
||||
|
||||
// Show floating action button when minimized
|
||||
if (isMinimized) {
|
||||
return div({
|
||||
style: {
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#4CAF50',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
onclick: () => setState('isMinimized', false),
|
||||
}, ['🔐']);
|
||||
}
|
||||
|
||||
// Show full UI when expanded
|
||||
return div({
|
||||
style: {
|
||||
position: 'fixed',
|
||||
bottom: '0',
|
||||
right: '8px',
|
||||
width: '280px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
}, [
|
||||
// Header with minimize button
|
||||
div({
|
||||
style: {
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}
|
||||
}, [
|
||||
div({}, ['Plugin Title']),
|
||||
button({
|
||||
onclick: () => setState('isMinimized', true),
|
||||
}, ['−'])
|
||||
]),
|
||||
// Content
|
||||
div({}, ['Plugin content here...'])
|
||||
]);
|
||||
}
|
||||
|
||||
export default { main };
|
||||
```
|
||||
|
||||
### Multiple State Variables
|
||||
|
||||
```javascript
|
||||
function toggleTheme() {
|
||||
const isDark = useState('isDark', false);
|
||||
setState('isDark', !isDark);
|
||||
}
|
||||
|
||||
function updateUser() {
|
||||
setState('user', {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const isDark = useState('isDark', false);
|
||||
const user = useState('user', null);
|
||||
const counter = useState('counter', 0);
|
||||
|
||||
return div({
|
||||
style: {
|
||||
backgroundColor: isDark ? '#333' : '#fff',
|
||||
color: isDark ? '#fff' : '#333',
|
||||
}
|
||||
}, [
|
||||
div({}, ['Theme: ' + (isDark ? 'Dark' : 'Light')]),
|
||||
button({ onclick: 'toggleTheme' }, ['Toggle Theme']),
|
||||
|
||||
user ?
|
||||
div({}, ['Welcome, ' + user.name]) :
|
||||
button({ onclick: 'updateUser' }, ['Login']),
|
||||
|
||||
div({}, ['Counter: ' + counter]),
|
||||
button({
|
||||
onclick: () => setState('counter', counter + 1)
|
||||
}, ['Increment'])
|
||||
]);
|
||||
}
|
||||
|
||||
export default { main, toggleTheme, updateUser };
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive State Keys
|
||||
|
||||
```javascript
|
||||
// Good
|
||||
const isRequestPending = useState('isRequestPending', false);
|
||||
const userProfile = useState('userProfile', null);
|
||||
|
||||
// Bad
|
||||
const state1 = useState('s1', false);
|
||||
const data = useState('d', null);
|
||||
```
|
||||
|
||||
### 2. Initialize with Appropriate Default Values
|
||||
|
||||
```javascript
|
||||
// Boolean states
|
||||
const isLoading = useState('isLoading', false);
|
||||
|
||||
// Numeric states
|
||||
const count = useState('count', 0);
|
||||
|
||||
// Object states
|
||||
const user = useState('user', null);
|
||||
|
||||
// Array states
|
||||
const items = useState('items', []);
|
||||
```
|
||||
|
||||
### 3. Prevent Unnecessary Re-renders
|
||||
|
||||
```javascript
|
||||
function onClick() {
|
||||
const currentValue = useState('value');
|
||||
const newValue = calculateNewValue();
|
||||
|
||||
// Only update if value actually changed
|
||||
if (currentValue !== newValue) {
|
||||
setState('value', newValue);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Handle Async Operations Properly
|
||||
|
||||
```javascript
|
||||
async function fetchData() {
|
||||
setState('loading', true);
|
||||
setState('error', null);
|
||||
|
||||
try {
|
||||
const data = await fetch(/* ... */);
|
||||
setState('data', data);
|
||||
} catch (error) {
|
||||
setState('error', error.message);
|
||||
} finally {
|
||||
setState('loading', false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Group Related State Updates
|
||||
|
||||
```javascript
|
||||
function resetForm() {
|
||||
// Update multiple related states together
|
||||
setState('formData', {});
|
||||
setState('formErrors', {});
|
||||
setState('isSubmitting', false);
|
||||
setState('submitSuccess', false);
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### State Persistence
|
||||
- State is stored in a Map structure within the plugin execution context
|
||||
- State persists across renders during the plugin lifecycle
|
||||
- State is isolated per plugin instance
|
||||
|
||||
### Re-rendering Behavior
|
||||
- Calling `setState` with a different value triggers a re-render
|
||||
- The `main()` function is called again after state changes
|
||||
- Re-renders are synchronous and immediate
|
||||
|
||||
### Deep Equality Checking
|
||||
- State updates use deep equality checking to prevent unnecessary re-renders
|
||||
- Objects and arrays are compared by value, not reference
|
||||
- Primitive values are compared directly
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Toggle Pattern
|
||||
|
||||
```javascript
|
||||
function toggleState() {
|
||||
const isEnabled = useState('isEnabled', false);
|
||||
setState('isEnabled', !isEnabled);
|
||||
}
|
||||
```
|
||||
|
||||
### Counter Pattern
|
||||
|
||||
```javascript
|
||||
function increment() {
|
||||
const count = useState('count', 0);
|
||||
setState('count', count + 1);
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
const count = useState('count', 0);
|
||||
setState('count', Math.max(0, count - 1));
|
||||
}
|
||||
```
|
||||
|
||||
### Form Input Pattern
|
||||
|
||||
```javascript
|
||||
function updateInput(field, value) {
|
||||
const formData = useState('formData', {});
|
||||
setState('formData', {
|
||||
...formData,
|
||||
[field]: value
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering Pattern
|
||||
|
||||
```javascript
|
||||
function main() {
|
||||
const view = useState('view', 'home');
|
||||
|
||||
switch(view) {
|
||||
case 'home':
|
||||
return renderHomeView();
|
||||
case 'settings':
|
||||
return renderSettingsView();
|
||||
case 'profile':
|
||||
return renderProfileView();
|
||||
default:
|
||||
return renderHomeView();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### State Not Updating
|
||||
- Ensure you're using `setState` to update state, not direct assignment
|
||||
- Check that the key is consistent across `useState` and `setState` calls
|
||||
- Verify that the new value is actually different from the current value
|
||||
|
||||
### Infinite Re-renders
|
||||
- Avoid calling `setState` directly in `main()` without conditions
|
||||
- Use `useEffect` for side effects that should only run once
|
||||
|
||||
### State Not Persisting
|
||||
- Make sure you're using the same key consistently
|
||||
- Check that you're not accidentally resetting state elsewhere
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Direct Variables
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
let isLoading = false;
|
||||
|
||||
function onClick() {
|
||||
isLoading = true;
|
||||
// No automatic re-render
|
||||
}
|
||||
|
||||
function main() {
|
||||
return div({}, [isLoading ? 'Loading...' : 'Ready']);
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
function onClick() {
|
||||
setState('isLoading', true);
|
||||
// Automatic re-render triggered
|
||||
}
|
||||
|
||||
function main() {
|
||||
const isLoading = useState('isLoading', false);
|
||||
return div({}, [isLoading ? 'Loading...' : 'Ready']);
|
||||
}
|
||||
```
|
||||
|
||||
### From External State Management
|
||||
|
||||
If migrating from external state management, useState provides a simpler, built-in alternative:
|
||||
|
||||
```javascript
|
||||
// No need for external state stores or contexts
|
||||
// State is managed internally by the plugin SDK
|
||||
|
||||
function main() {
|
||||
// State is automatically injected and managed
|
||||
const appState = useState('appState', {
|
||||
user: null,
|
||||
settings: {},
|
||||
ui: {
|
||||
theme: 'light',
|
||||
sidebarOpen: false
|
||||
}
|
||||
});
|
||||
|
||||
// Use state directly in rendering
|
||||
return renderApp(appState);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **State Granularity**: Keep state variables focused and granular to minimize re-render scope
|
||||
2. **Complex Objects**: When updating nested objects, create new references to trigger re-renders
|
||||
3. **Computed Values**: Calculate derived values during render rather than storing in state
|
||||
4. **Batch Updates**: Multiple `setState` calls in the same execution context will trigger only one re-render
|
||||
|
||||
## Related APIs
|
||||
|
||||
- `useEffect`: For managing side effects
|
||||
- `useHeaders`: For subscribing to HTTP headers
|
||||
- `useRequests`: For subscribing to HTTP requests
|
||||
- `openWindow`: For opening browser windows
|
||||
- `prove`: For generating TLS proofs
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { Host } from './index';
|
||||
|
||||
// Skip this test in browser environment since QuickJS requires Node.js
|
||||
@@ -63,4 +63,329 @@ describe.skipIf(typeof window !== 'undefined')('Host', () => {
|
||||
}
|
||||
sandbox.dispose();
|
||||
});
|
||||
|
||||
describe('useState functionality', () => {
|
||||
let eventEmitter: any;
|
||||
let renderCallCount: number;
|
||||
let lastRenderedUi: any;
|
||||
|
||||
beforeEach(() => {
|
||||
renderCallCount = 0;
|
||||
lastRenderedUi = null;
|
||||
|
||||
// Create mock event emitter
|
||||
eventEmitter = {
|
||||
listeners: new Set<Function>(),
|
||||
addListener: vi.fn((listener: Function) => {
|
||||
eventEmitter.listeners.add(listener);
|
||||
}),
|
||||
removeListener: vi.fn((listener: Function) => {
|
||||
eventEmitter.listeners.delete(listener);
|
||||
}),
|
||||
emit: (message: any) => {
|
||||
eventEmitter.listeners.forEach((listener: Function) => listener(message));
|
||||
},
|
||||
};
|
||||
|
||||
// Create host with mock callbacks that track renders
|
||||
host = new Host({
|
||||
onProve: vi.fn(),
|
||||
onRenderPluginUi: vi.fn((windowId: number, ui: any) => {
|
||||
renderCallCount++;
|
||||
lastRenderedUi = ui;
|
||||
}),
|
||||
onCloseWindow: vi.fn(),
|
||||
onOpenWindow: vi.fn().mockResolvedValue({
|
||||
type: 'WINDOW_OPENED',
|
||||
payload: {
|
||||
windowId: 123,
|
||||
uuid: 'test-uuid',
|
||||
tabId: 456,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize state with default value', async () => {
|
||||
const plugin = `
|
||||
let stateValue = null;
|
||||
|
||||
async function init() {
|
||||
// Open a window to get a windowId for rendering
|
||||
await openWindow('https://example.com');
|
||||
}
|
||||
|
||||
function main() {
|
||||
stateValue = useState('testKey', 'defaultValue');
|
||||
|
||||
// Initialize on first render
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
return div({}, [stateValue]);
|
||||
}
|
||||
|
||||
export default { main };
|
||||
`;
|
||||
|
||||
const promise = host.executePlugin(plugin, { eventEmitter });
|
||||
|
||||
// Wait for window to open and initial render
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(renderCallCount).toBeGreaterThan(0);
|
||||
expect(lastRenderedUi.children).toEqual(['defaultValue']);
|
||||
});
|
||||
|
||||
it('should persist state across renders', async () => {
|
||||
const plugin = `
|
||||
let clickCount = 0;
|
||||
|
||||
async function init() {
|
||||
await openWindow('https://example.com');
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
const count = useState('clickCount', 0);
|
||||
setState('clickCount', count + 1);
|
||||
}
|
||||
|
||||
function main() {
|
||||
clickCount = useState('clickCount', 0);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
return div({}, [
|
||||
div({}, ['Count: ' + clickCount]),
|
||||
button({ onclick: 'onClick' }, ['Increment'])
|
||||
]);
|
||||
}
|
||||
|
||||
export default { main, onClick };
|
||||
`;
|
||||
|
||||
const promise = host.executePlugin(plugin, { eventEmitter });
|
||||
|
||||
// Wait for initial render
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
expect(renderCallCount).toBeGreaterThan(0);
|
||||
const initialRenderCount = renderCallCount;
|
||||
expect(lastRenderedUi.children[0].children).toEqual(['Count: 0']);
|
||||
|
||||
// Simulate button click
|
||||
eventEmitter.emit({
|
||||
type: 'PLUGIN_UI_CLICK',
|
||||
onclick: 'onClick',
|
||||
});
|
||||
|
||||
// Wait for re-render after state change
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(initialRenderCount + 1);
|
||||
expect(lastRenderedUi.children[0].children).toEqual(['Count: 1']);
|
||||
|
||||
// Click again
|
||||
eventEmitter.emit({
|
||||
type: 'PLUGIN_UI_CLICK',
|
||||
onclick: 'onClick',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(initialRenderCount + 2);
|
||||
expect(lastRenderedUi.children[0].children).toEqual(['Count: 2']);
|
||||
});
|
||||
|
||||
it('should trigger re-render when state changes', async () => {
|
||||
const plugin = `
|
||||
function onClick() {
|
||||
setState('isLoading', true);
|
||||
|
||||
// Simulate async operation
|
||||
setTimeout(() => {
|
||||
setState('isLoading', false);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const isLoading = useState('isLoading', false);
|
||||
|
||||
if (isLoading) {
|
||||
return div({}, ['Loading...']);
|
||||
}
|
||||
|
||||
return button({ onclick: 'onClick' }, ['Start']);
|
||||
}
|
||||
|
||||
export default { main, onClick };
|
||||
`;
|
||||
|
||||
const promise = host.executePlugin(plugin, { eventEmitter });
|
||||
|
||||
// Initial render
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(1);
|
||||
expect(lastRenderedUi.type).toBe('button');
|
||||
expect(lastRenderedUi.children).toEqual(['Start']);
|
||||
|
||||
// Click button to trigger loading state
|
||||
eventEmitter.emit({
|
||||
type: 'PLUGIN_UI_CLICK',
|
||||
onclick: 'onClick',
|
||||
});
|
||||
|
||||
// Wait for loading state render
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(renderCallCount).toBe(2);
|
||||
expect(lastRenderedUi.children).toEqual(['Loading...']);
|
||||
|
||||
// Wait for async operation to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(3);
|
||||
expect(lastRenderedUi.type).toBe('button');
|
||||
expect(lastRenderedUi.children).toEqual(['Start']);
|
||||
});
|
||||
|
||||
it('should handle multiple state keys independently', async () => {
|
||||
const plugin = `
|
||||
function toggleMinimized() {
|
||||
const isMinimized = useState('isMinimized', false);
|
||||
setState('isMinimized', !isMinimized);
|
||||
}
|
||||
|
||||
function incrementCounter() {
|
||||
const counter = useState('counter', 0);
|
||||
setState('counter', counter + 1);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const isMinimized = useState('isMinimized', false);
|
||||
const counter = useState('counter', 0);
|
||||
|
||||
return div({}, [
|
||||
div({}, ['Minimized: ' + isMinimized]),
|
||||
div({}, ['Counter: ' + counter]),
|
||||
button({ onclick: 'toggleMinimized' }, ['Toggle']),
|
||||
button({ onclick: 'incrementCounter' }, ['Increment'])
|
||||
]);
|
||||
}
|
||||
|
||||
export default { main, toggleMinimized, incrementCounter };
|
||||
`;
|
||||
|
||||
const promise = host.executePlugin(plugin, { eventEmitter });
|
||||
|
||||
// Initial render
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(1);
|
||||
expect(lastRenderedUi.children[0].children).toEqual(['Minimized: false']);
|
||||
expect(lastRenderedUi.children[1].children).toEqual(['Counter: 0']);
|
||||
|
||||
// Toggle minimized
|
||||
eventEmitter.emit({
|
||||
type: 'PLUGIN_UI_CLICK',
|
||||
onclick: 'toggleMinimized',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(2);
|
||||
expect(lastRenderedUi.children[0].children).toEqual(['Minimized: true']);
|
||||
expect(lastRenderedUi.children[1].children).toEqual(['Counter: 0']);
|
||||
|
||||
// Increment counter
|
||||
eventEmitter.emit({
|
||||
type: 'PLUGIN_UI_CLICK',
|
||||
onclick: 'incrementCounter',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(3);
|
||||
expect(lastRenderedUi.children[0].children).toEqual(['Minimized: true']);
|
||||
expect(lastRenderedUi.children[1].children).toEqual(['Counter: 1']);
|
||||
});
|
||||
|
||||
it('should not re-render if state value does not change', async () => {
|
||||
const plugin = `
|
||||
function onClick() {
|
||||
setState('value', 'same');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const value = useState('value', 'same');
|
||||
return button({ onclick: 'onClick' }, [value]);
|
||||
}
|
||||
|
||||
export default { main, onClick };
|
||||
`;
|
||||
|
||||
const promise = host.executePlugin(plugin, { eventEmitter });
|
||||
|
||||
// Initial render
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(1);
|
||||
|
||||
// Click button - setting same value
|
||||
eventEmitter.emit({
|
||||
type: 'PLUGIN_UI_CLICK',
|
||||
onclick: 'onClick',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Should not trigger re-render since value is the same
|
||||
expect(renderCallCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle complex state objects', async () => {
|
||||
const plugin = `
|
||||
function updateUser() {
|
||||
setState('user', {
|
||||
name: 'John Doe',
|
||||
age: 30,
|
||||
email: 'john@example.com'
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const user = useState('user', null);
|
||||
|
||||
if (!user) {
|
||||
return button({ onclick: 'updateUser' }, ['Load User']);
|
||||
}
|
||||
|
||||
return div({}, [
|
||||
div({}, ['Name: ' + user.name]),
|
||||
div({}, ['Age: ' + user.age]),
|
||||
div({}, ['Email: ' + user.email])
|
||||
]);
|
||||
}
|
||||
|
||||
export default { main, updateUser };
|
||||
`;
|
||||
|
||||
const promise = host.executePlugin(plugin, { eventEmitter });
|
||||
|
||||
// Initial render
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(1);
|
||||
expect(lastRenderedUi.type).toBe('button');
|
||||
|
||||
// Update user state
|
||||
eventEmitter.emit({
|
||||
type: 'PLUGIN_UI_CLICK',
|
||||
onclick: 'updateUser',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(renderCallCount).toBe(2);
|
||||
expect(lastRenderedUi.children[0].children).toEqual(['Name: John Doe']);
|
||||
expect(lastRenderedUi.children[1].children).toEqual(['Age: 30']);
|
||||
expect(lastRenderedUi.children[2].children).toEqual(['Email: john@example.com']);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,31 @@ function createDomJson(
|
||||
};
|
||||
}
|
||||
|
||||
// Pure function for creating useState hook without `this` binding
|
||||
function makeUseState(
|
||||
uuid: string,
|
||||
stateStore: Map<string, any>,
|
||||
triggerRerender: () => void,
|
||||
) {
|
||||
const useState = (key: string, defaultValue?: any) => {
|
||||
if (!stateStore.has(key) && defaultValue !== undefined) {
|
||||
stateStore.set(key, defaultValue);
|
||||
}
|
||||
return stateStore.get(key);
|
||||
};
|
||||
|
||||
const setState = (key: string, value: any) => {
|
||||
const currentValue = stateStore.get(key);
|
||||
if (!deepEqual(currentValue, value)) {
|
||||
stateStore.set(key, value);
|
||||
// Trigger re-render when state changes
|
||||
triggerRerender();
|
||||
}
|
||||
};
|
||||
|
||||
return { useState, setState };
|
||||
}
|
||||
|
||||
// Pure function for creating useEffect hook without `this` binding
|
||||
function makeUseEffect(
|
||||
uuid: string,
|
||||
@@ -469,6 +494,9 @@ ${code};
|
||||
};
|
||||
} = {};
|
||||
|
||||
// State store for useState hook
|
||||
const stateStore = new Map<string, any>();
|
||||
|
||||
let doneResolve: (args?: any[]) => void;
|
||||
|
||||
const donePromise = new Promise((resolve) => {
|
||||
@@ -501,6 +529,22 @@ ${code};
|
||||
const onOpenWindow = this.onOpenWindow;
|
||||
const onProve = this.onProve;
|
||||
|
||||
// Create a reference to main that can be called for re-renders
|
||||
let mainFunctionRef: (() => any) | null = null;
|
||||
|
||||
// Create trigger function for state changes
|
||||
const triggerRerender = () => {
|
||||
if (mainFunctionRef) {
|
||||
const executionContext = executionContextRegistry.get(uuid);
|
||||
if (executionContext) {
|
||||
executionContext.main();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create useState and setState functions
|
||||
const { useState, setState } = makeUseState(uuid, stateStore, triggerRerender);
|
||||
|
||||
const sandbox = await this.createEvalCode({
|
||||
div: (param1?: DomOptions | DomJson[], param2?: DomJson[]) =>
|
||||
createDomJson('div', param1, param2),
|
||||
@@ -510,6 +554,8 @@ ${code};
|
||||
useEffect: makeUseEffect(uuid, context),
|
||||
useRequests: makeUseRequests(uuid, context),
|
||||
useHeaders: makeUseHeaders(uuid, context),
|
||||
useState,
|
||||
setState,
|
||||
prove: onProve,
|
||||
done: (args?: any[]) => {
|
||||
// Close the window if it exists
|
||||
@@ -528,6 +574,8 @@ const openWindow = env.openWindow;
|
||||
const useEffect = env.useEffect;
|
||||
const useRequests = env.useRequests;
|
||||
const useHeaders = env.useHeaders;
|
||||
const useState = env.useState;
|
||||
const setState = env.setState;
|
||||
const prove = env.prove;
|
||||
const closeWindow = env.closeWindow;
|
||||
const done = env.done;
|
||||
@@ -596,6 +644,9 @@ ${code};
|
||||
}
|
||||
};
|
||||
|
||||
// Set the main function reference for re-rendering on state changes
|
||||
mainFunctionRef = main;
|
||||
|
||||
executionContextRegistry.set(uuid, {
|
||||
id: uuid,
|
||||
plugin: code,
|
||||
|
||||
Reference in New Issue
Block a user