Add useState hook to plugin-sdk with state persistence, re-rendering, and DevConsole UI enhancements

This commit is contained in:
tsukino
2025-11-08 15:42:12 -08:00
parent 5d281e9833
commit 730ce1754c
4 changed files with 1012 additions and 46 deletions

View File

@@ -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,
};
`;

View 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

View File

@@ -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();
});
});
});

View File

@@ -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,