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
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1767776390185.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rozenite/plugin-bridge': minor
---

Add shared storage API for synchronizing state between DevTools panel and device. Plugins can now use `createRozeniteSharedStorage` and `useRozeniteSharedStorage` to maintain persistent, synchronized state across the host-device boundary with automatic localStorage persistence on the host side.
3 changes: 3 additions & 0 deletions packages/plugin-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ export type { RozeniteDevToolsClient } from './client';
export type { Subscription } from './types';
export type { UseRozeniteDevToolsClientOptions } from './useRozeniteDevToolsClient';
export { getRozeniteDevToolsClient } from './client';
export { createRozeniteSharedStorage } from './storage/shared-storage';
export type { RozeniteSharedStorage } from './storage/shared-storage';
export { useRozeniteSharedStorage } from './storage/useSharedStorage';
export { UnsupportedPlatformError } from './errors';
197 changes: 197 additions & 0 deletions packages/plugin-bridge/src/storage/shared-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { RozeniteDevToolsClient } from '../client';

const isPanel =
typeof window !== 'undefined' && '__ROZENITE_PANEL__' in window;

export type RozeniteSharedStorageEventMap<T> = {
'rozenite-storage-init': { defaults: T };
'rozenite-storage-sync': { data: T };
'rozenite-storage-update': { key: keyof T; value: T[keyof T] };
};

export type RozeniteSharedStorage<T> = {
connect: (
client: RozeniteDevToolsClient<RozeniteSharedStorageEventMap<T>>
) => void;
get: <K extends keyof T>(key: K) => T[K];
set: <K extends keyof T>(key: K, value: T[K]) => void;
getSnapshot: () => T;
subscribe: (listener: (data: T) => void) => () => void;
isSynchronized: () => boolean;
};

const createHostStorage = <T extends Record<string, unknown>>(
pluginId: string,
defaults: T
): RozeniteSharedStorage<T> => {
let data: T = { ...defaults };
const listeners = new Set<(data: T) => void>();
let client: RozeniteDevToolsClient<
RozeniteSharedStorageEventMap<T>
> | null = null;

const loadFromPersistence = () => {
try {
const stored = localStorage.getItem(`rozenite-storage-${pluginId}`);
if (stored) {
data = { ...defaults, ...JSON.parse(stored) };
}
} catch (e) {
console.error('Failed to load storage', e);
}
};

const saveToPersistence = () => {
try {
localStorage.setItem(
`rozenite-storage-${pluginId}`,
JSON.stringify(data)
);
} catch (e) {
console.error('Failed to save storage', e);
}
};

const notifyListeners = () => {
listeners.forEach((listener) => listener(data));
};

const handleInit = (deviceDefaults: T) => {
let hasData = false;
try {
const stored = localStorage.getItem(`rozenite-storage-${pluginId}`);
if (stored) {
hasData = true;
}
} catch {
// ignore
}

if (!hasData) {
// Adopt defaults
data = { ...deviceDefaults };
saveToPersistence();
}

// Send Sync
client?.send('rozenite-storage-sync', { data: data });
};

loadFromPersistence();

return {
connect: (newClient) => {
client = newClient;
client.onMessage(
'rozenite-storage-init',
(payload: { defaults: T }) => {
handleInit(payload.defaults);
}
);
},

get: <K extends keyof T>(key: K): T[K] => {
return data[key];
},

getSnapshot: (): T => {
return data;
},

set: <K extends keyof T>(key: K, value: T[K]) => {
data[key] = value;
notifyListeners();
saveToPersistence();
client?.send('rozenite-storage-update', { key, value });
},

subscribe: (listener: (data: T) => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},

isSynchronized: () => true,
};
};

const createDeviceStorage = <T extends Record<string, unknown>>(
defaults: T
): RozeniteSharedStorage<T> => {
let data: T = { ...defaults };
const listeners = new Set<(data: T) => void>();
let client: RozeniteDevToolsClient<
RozeniteSharedStorageEventMap<T>
> | null = null;
let synchronized = false;

const notifyListeners = () => {
listeners.forEach((listener) => listener(data));
};

const handleSync = (newData: T) => {
data = { ...newData };
synchronized = true;
notifyListeners();
};

const handleUpdate = <K extends keyof T>(key: K, value: T[K]) => {
data[key] = value;
notifyListeners();
};

return {
connect: (newClient) => {
client = newClient;

client.onMessage('rozenite-storage-sync', (payload: { data: T }) => {
handleSync(payload.data);
});

client.onMessage(
'rozenite-storage-update',
(payload: { key: keyof T; value: T[keyof T] }) => {
handleUpdate(payload.key, payload.value);
}
);

// Send Init
client.send('rozenite-storage-init', { defaults });
},

get: <K extends keyof T>(key: K): T[K] => {
return data[key];
},

getSnapshot: (): T => {
return data;
},

set: <K extends keyof T>(key: K, value: T[K]) => {
data[key] = value;
notifyListeners();
// Device logic: only update local, do not sync back to host for now
},

subscribe: (listener: (data: T) => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},

isSynchronized: () => synchronized,
};
};

export const createRozeniteSharedStorage = <T extends Record<string, unknown>>(
pluginId: string,
defaults: T
): RozeniteSharedStorage<T> => {
if (isPanel) {
return createHostStorage(pluginId, defaults);
}

return createDeviceStorage(defaults);
};
81 changes: 81 additions & 0 deletions packages/plugin-bridge/src/storage/useSharedStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState, useEffect } from 'react';
import { RozeniteSharedStorage } from './shared-storage';

const useSynchronizedRozeniteSharedStorage = <T extends Record<string, unknown>>(
storage: RozeniteSharedStorage<T>
): T | null => {
const [data, setData] = useState<T | null>(() => {
if (storage.isSynchronized()) {
return storage.getSnapshot();
}
return null;
});

useEffect(() => {
if (storage.isSynchronized()) {
// It's possible it synced between init and effect
setData((prev) => {
// Optimization: don't update if already set (though reference comparison might fail if snapshot is new)
if (prev === null) return storage.getSnapshot();
return prev;
});
}

return storage.subscribe((newData) => {
if (storage.isSynchronized()) {
setData({ ...newData });
} else {
setData(null);
}
});
}, [storage]);

return data;
};

const useUnsynchronizedRozeniteSharedStorage = <T extends Record<string, unknown>>(
storage: RozeniteSharedStorage<T>
): T => {
const [data, setData] = useState<T>(() => storage.getSnapshot());

useEffect(() => {
// In case storage ref changed or just to ensure freshness (though usually storage instance is stable)
setData(storage.getSnapshot());

return storage.subscribe((newData) => {
setData({ ...newData });
});
}, [storage]);

return data;
};

export type UseRozeniteSharedStorageOptions<TEnsureSynchronized extends boolean = boolean> = {
ensureSynchronized?: TEnsureSynchronized;
};

export function useRozeniteSharedStorage<T extends Record<string, unknown>>(
storage: RozeniteSharedStorage<T>,
options: UseRozeniteSharedStorageOptions<true>
): T | null;

export function useRozeniteSharedStorage<T extends Record<string, unknown>>(
storage: RozeniteSharedStorage<T>,
options?: UseRozeniteSharedStorageOptions<false>
): T;

export function useRozeniteSharedStorage<T extends Record<string, unknown>>(
storage: RozeniteSharedStorage<T>,
options?: UseRozeniteSharedStorageOptions
): T | null {
// Freeze the option on first render to prevent hook switching at runtime
const [ensureSynchronized] = useState(
() => options?.ensureSynchronized ?? false
);

if (ensureSynchronized) {
return useSynchronizedRozeniteSharedStorage(storage);
}

return useUnsynchronizedRozeniteSharedStorage(storage);
}
7 changes: 6 additions & 1 deletion website/src/docs/plugin-development/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@
"type": "file",
"name": "plugin-development",
"label": "Plugin Development Guide"
},
{
"type": "file",
"name": "shared-storage",
"label": "Shared Storage"
}
]
]
1 change: 1 addition & 0 deletions website/src/docs/plugin-development/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ my-plugin/

- **React Native API Access**: Leverage React Native APIs and libraries
- **Type Safety**: Full TypeScript support with compile-time checking
- **Shared Storage**: Synchronized key-value store shared between App and DevTools
- **Hot Reloading**: See changes instantly during development
- **Production Builds**: Optimized builds for distribution

Expand Down
8 changes: 7 additions & 1 deletion website/src/docs/plugin-development/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,13 @@ export default function setupPlugin(
}
```

## Step 5: Local Development Workflow
## Step 5: Shared Storage (Optional)

If your plugin needs to persist settings or share a synchronized state between the app and the DevTools panels, you can use Rozenite's **Shared Storage**.

Check out the [Shared Storage Guide](./shared-storage.md) to learn how to implement synchronous, persisted state for your plugin.

## Step 6: Local Development Workflow

### Complete Development Setup

Expand Down
Loading