Skip to content

Commit 6c01b8d

Browse files
committed
feat: synced state hooks (useSyncedState / useSyncedMap) + runtime API docs
Add Figma-style synced state: hooks whose value lives in a host-owned store outside the VM, instead of VM-local useState. Synced state is shared across a plugin's surfaces, drivable and observable from the host (plugin.synced), and optionally persisted to IndexedDB so it survives reloads. - SyncedStore (host): keyed values, all-key and per-key subscriptions, optional ClientStorage persistence with hydrate-before-run. - In-VM hooks useSyncedState(key, initial) and useSyncedMap(name), wired to the store through the __zushi bridge; JsxHost pumps the job loop on any store change so host-initiated writes re-render the plugin. - Plugin `synced` option (initial / persist / storage / instanceId / onChange) and `plugin.synced` for the host side; Plugin.start() hydrates before eval. - Exposed via the explicit-import and react-compat shims; added to RUNTIME_API_NAMES so placement works like any runtime API. Docs: new docs/jsx/synced.md, and docs/jsx/runtime-api.md — a proper reference for every exposed runtime function (elements, all hooks, context, memo, ErrorBoundary/Suspense, registerComponent) with signatures and behavior, since the overview only listed names. Adds a synced-state example (two-way + persisted).
1 parent edfe061 commit 6c01b8d

19 files changed

Lines changed: 792 additions & 5 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Full docs live in [`docs/`](./docs):
6262

6363
- **[Getting started](./docs/getting-started.md)** · [Concepts](./docs/concepts.md) (architecture, event loop, messaging, lifecycle)
6464
- **Core**[Backends](./docs/backends.md) · [Surfaces & iframes](./docs/surfaces.md) · [Exposing a host API](./docs/exposing-api.md)
65-
- **JSX UI (opt-in)**[Overview](./docs/jsx/overview.md) · [Runtime API placement](./docs/jsx/placement.md) · [Components & intrinsics](./docs/jsx/components.md) · [Renderers (canvas, etc.)](./docs/jsx/renderers.md)
65+
- **JSX UI (opt-in)**[Overview](./docs/jsx/overview.md) · [Runtime API reference](./docs/jsx/runtime-api.md) · [Placement](./docs/jsx/placement.md) · [Synced state](./docs/jsx/synced.md) · [Components & intrinsics](./docs/jsx/components.md) · [Renderers (canvas, etc.)](./docs/jsx/renderers.md)
6666
- **Integration**[React adapter](./docs/react.md) · [Storage & events](./docs/storage-and-events.md) · [Security model](./docs/security.md)
6767

6868
Exact option/type signatures are documented inline via TSDoc and shipped as

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ See [Concepts](./concepts.md) for the full data-flow picture.
3131

3232
### JSX UI (opt-in)
3333
- [JSX overview](./jsx/overview.md) — the in-VM runtime, hooks, writing JSX
34+
- [Runtime API reference](./jsx/runtime-api.md) — every hook & exposed function, with signatures
3435
- [Runtime API placement](./jsx/placement.md) — namespace, `runtime` refs, the `setup` slot, bridge sealing
36+
- [Synced state](./jsx/synced.md)`useSyncedState` / `useSyncedMap` (shared, host-drivable, persisted)
3537
- [Components & intrinsics](./jsx/components.md)`registerComponent`, trust, restricting HTML
3638
- [Renderers](./jsx/renderers.md) — iframe vs host-direct, react-konva, the patcher contract
3739

docs/jsx/overview.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ new Plugin({
2929
- **Hooks**: `useState`, `useReducer`, `useEffect`, `useLayoutEffect` (alias of
3030
`useEffect` — there's no separate layout phase in the VM), `useMemo`,
3131
`useCallback`, `useRef`, `useId`, `createContext` / `useContext`.
32+
- **[Synced state](./synced.md)**: `useSyncedState` / `useSyncedMap` — like
33+
`useState` but in a host-owned store that's shared across surfaces, drivable
34+
from the host, and optionally persisted.
3235
- **Also**: `memo(Component, areEqual?)`, an `ErrorBoundary`
3336
(`{ fallback, onError }`), and a basic `Suspense` (`{ fallback }`) that shows
3437
the fallback while a child throws a thenable and re-renders when it settles.
@@ -40,6 +43,9 @@ Without `surface` it targets the surface named `"ui"` (or the only one declared
4043
if there's a single surface under another name). Each surface reconciles
4144
independently.
4245

46+
**Signatures and per-function behavior:** see the
47+
[runtime API reference](./runtime-api.md).
48+
4349
## How it works (and why it's safe)
4450

4551
- **Reconciliation runs in the VM.** Components and hooks are resolved there into
@@ -86,6 +92,8 @@ survives VM marshaling and bundling — identity is by value.
8692

8793
## Next
8894

95+
- [Runtime API reference](./runtime-api.md) — every hook & function, with signatures
8996
- [Runtime API placement](./placement.md) — where `useState` & co. land, and the `setup` slot
97+
- [Synced state](./synced.md) — shared/persisted state via `useSyncedState` / `useSyncedMap`
9098
- [Components & intrinsics](./components.md) — curated component vocabularies, restricting HTML
9199
- [Renderers](./renderers.md) — draw to canvas instead of DOM

docs/jsx/runtime-api.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# JSX runtime API reference
2+
3+
The functions the in-VM JSX runtime exposes to plugin code (`jsx: true`). Where
4+
they land in plugin scope — `zushi.useState`, bare, custom — is up to the host;
5+
see [placement](./placement.md). All entries below are also importable from
6+
`@reearth/zushi/jsx` for bundled plugins.
7+
8+
Behavior is React-*like* but VM-local: reconciliation and hooks run inside the
9+
VM, and only a serialized tree crosses to the host (see
10+
[overview](./overview.md#how-it-works-and-why-its-safe)).
11+
12+
## Elements
13+
14+
### `createElement(type, props?, ...children)` · `h`
15+
16+
Create a virtual node. `type` is an intrinsic tag name (string), a component
17+
function, or `Fragment`. `h` is an alias. Returns a `VNode`.
18+
19+
```ts
20+
h("div", { className: "row" }, h("span", null, "hi"))
21+
```
22+
23+
### `Fragment`
24+
25+
Groups children without a wrapper element. It's the literal string
26+
`"__zushi.Fragment"` (so it survives marshaling/bundling), used as a `type`.
27+
28+
### `render(element, options?)`
29+
30+
Mount an element tree into a surface and start reconciling it.
31+
32+
```ts
33+
render(element, {
34+
surface?: string, // target surface (default "ui" / the only one)
35+
visible?: boolean,
36+
width?: number | string,
37+
height?: number | string
38+
})
39+
```
40+
41+
## State
42+
43+
### `useState(initial)`
44+
45+
```ts
46+
const [value, setValue] = useState(initial /* value | () => value */);
47+
setValue(next); // value | (prev) => next
48+
```
49+
VM-local, per-component, ephemeral. Updates re-render when the value changes
50+
(compared with `Object.is`).
51+
52+
### `useReducer(reducer, initialArg, init?)`
53+
54+
```ts
55+
const [state, dispatch] = useReducer((state, action) => next, initialArg, init?);
56+
```
57+
58+
### `useRef(initial)`
59+
60+
```ts
61+
const ref = useRef(initial); // { current }, stable across renders, no re-render on change
62+
```
63+
64+
### Synced state — `useSyncedState`, `useSyncedMap`
65+
66+
State that lives in the host-owned store instead of VM memory (shared across
67+
surfaces, host-drivable, optionally persisted). Full docs:
68+
**[Synced state](./synced.md)**.
69+
70+
```ts
71+
const [n, setN] = useSyncedState("count", 0);
72+
const map = useSyncedMap("items"); // get/has/set/delete/keys/values/entries/size
73+
```
74+
75+
## Effects
76+
77+
### `useEffect(effect, deps?)`
78+
79+
```ts
80+
useEffect(() => {
81+
// run after render (when deps change, or every render if deps omitted)
82+
return () => { /* cleanup before next run / on unmount */ };
83+
}, [a, b]);
84+
```
85+
Deps are compared with `Object.is`.
86+
87+
### `useLayoutEffect(effect, deps?)`
88+
89+
An **alias of `useEffect`** — there's no separate layout phase in the VM.
90+
91+
## Memoization
92+
93+
### `useMemo(factory, deps?)` · `useCallback(cb, deps?)`
94+
95+
```ts
96+
const value = useMemo(() => compute(a), [a]);
97+
const cb = useCallback(() => doThing(a), [a]); // === useMemo(() => cb, deps)
98+
```
99+
100+
### `memo(Component, areEqual?)`
101+
102+
Wrap a component so it re-uses its last render when props are shallow-equal (and
103+
its own state hasn't changed). `areEqual(prev, next)` overrides the comparison.
104+
105+
## Context
106+
107+
### `createContext(defaultValue)` · `useContext(context)`
108+
109+
```ts
110+
const Ctx = createContext(defaultValue);
111+
render(h(Ctx.Provider, { value }, children));
112+
const value = useContext(Ctx); // nearest enclosing Provider value, else default
113+
```
114+
115+
## Misc
116+
117+
### `useId()`
118+
119+
Returns a stable, per-component-instance id string (for form/aria wiring).
120+
121+
## Components
122+
123+
### `ErrorBoundary({ fallback, onError?, children })`
124+
125+
Catches errors thrown while rendering its subtree and shows `fallback` (a node
126+
or `(error) => node`) instead; `onError(error)` is called when it catches.
127+
128+
### `Suspense({ fallback, children })`
129+
130+
Shows `fallback` while a child throws a thenable, then re-renders when it
131+
settles. There is no `lazy()` — the VM has no module loader.
132+
133+
### `registerComponent(name, fn)`
134+
135+
Registers a trusted custom component (exposed as a bare global by name). Sealed
136+
from plugin code by default — only the trusted `setup` slot can call it. See
137+
[Components & intrinsics](./components.md).

docs/jsx/synced.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Synced state
2+
3+
`useState` lives in VM memory: it's local to a component, lost on dispose, and
4+
invisible to the host. **Synced state** is the opposite — it lives in a
5+
host-owned store outside the VM, so it:
6+
7+
- is **shared** across all of a plugin's surfaces,
8+
- can be **read, written, and observed by the host** (`plugin.synced`),
9+
- **survives dispose**, and can **persist across reloads** (IndexedDB),
10+
- and re-renders the plugin whenever it changes (from either side).
11+
12+
It's modeled on Figma's `useSyncedState` / `useSyncedMap`.
13+
14+
## `useSyncedState`
15+
16+
Like `useState`, but keyed into the shared store:
17+
18+
```ts
19+
new Plugin({
20+
backend: quickjs(),
21+
jsx: true,
22+
surfaces: { ui: { container } },
23+
synced: { initial: { count: 0 } }, // optional seed
24+
code: `
25+
const { useSyncedState, h, render } = zushi;
26+
function Counter() {
27+
const [n, setN] = useSyncedState("count", 0);
28+
return h("button", { onClick: () => setN(n + 1) }, "count: " + n);
29+
}
30+
render(h(Counter));
31+
`
32+
});
33+
```
34+
35+
`useSyncedState(key, initial)` returns `[value, setValue]`. The `key` namespaces
36+
the value in the shared store; `initial` is the fallback until something is set.
37+
`setValue` accepts a value or an updater `(prev) => next`.
38+
39+
## `useSyncedMap`
40+
41+
A synced last-writer-wins map stored under one name — handy for keyed
42+
collections:
43+
44+
```ts
45+
const items = useSyncedMap("items");
46+
items.set("a", 1);
47+
items.get("a"); // 1
48+
items.has("a"); // true
49+
items.delete("a");
50+
items.keys(); // string[]
51+
items.values(); // unknown[]
52+
items.entries(); // [string, unknown][]
53+
items.size; // number
54+
```
55+
56+
Mutations write a new object through the store, so reads stay reactive.
57+
58+
## The host side: `plugin.synced`
59+
60+
When `jsx` is enabled, `plugin.synced` is the same store — the host can drive and
61+
observe it, so host UI and the plugin share one state:
62+
63+
```ts
64+
plugin.synced!.get("count"); // read
65+
plugin.synced!.set("count", 5); // write → plugin re-renders
66+
plugin.synced!.delete("count");
67+
plugin.synced!.keys(); // string[]
68+
const off = plugin.synced!.subscribe((key, value) => { /* on any change */ });
69+
```
70+
71+
## Configuration & persistence
72+
73+
The `synced` plugin option configures the store:
74+
75+
```ts
76+
synced: {
77+
initial?: Record<string, unknown>; // seed values
78+
persist?: boolean; // persist across reloads
79+
storage?: ClientStorage; // backing store (default: a new one)
80+
instanceId?: string; // namespaces persisted keys (default "default")
81+
onChange?: (key, value) => void; // observe every change
82+
}
83+
```
84+
85+
With `persist: true`, values are written through to IndexedDB (via
86+
[`ClientStorage`](../storage-and-events.md#clientstorage)) and **loaded back
87+
into memory before the plugin runs** (`Plugin.start()` awaits hydration), so the
88+
first render sees persisted values. Use `instanceId` to scope one plugin
89+
instance's state.
90+
91+
## Notes
92+
93+
- Values must be **plain data** — they cross the VM boundary and may be
94+
persisted, so functions and class instances won't round-trip.
95+
- Synced state is a JSX-runtime feature (`jsx: true`); the hooks are placed like
96+
any other runtime API (see [placement](./placement.md)).
97+
- vs `useState`: reach for synced state when the value must outlive a render
98+
tree, be shared with the host, or persist; use plain `useState` for transient
99+
view state.

docs/storage-and-events.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ await storage.dropStore(instanceId); // wipe an instance's store
2222
instances without collisions. The backing store name defaults to
2323
`zushi-plugin-${instanceId}`.
2424

25+
`ClientStorage` is also the persistence backing for
26+
[synced state](./jsx/synced.md) (`useSyncedState` with `persist: true`).
27+
2528
## The event emitter
2629

2730
`events<E>()` creates a typed event emitter, returning a `[listener, emit]`

examples/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { JsxExample } from "./jsxExample";
22
import { KonvaExample } from "./konvaExample";
33
import { ReactExample } from "./reactExample";
4+
import { SyncedExample } from "./syncedExample";
45
import { VanillaExample } from "./vanillaExample";
56

67
export function App() {
@@ -17,6 +18,7 @@ export function App() {
1718
<ReactExample />
1819
<VanillaExample />
1920
<JsxExample />
21+
<SyncedExample />
2022
<KonvaExample />
2123
</div>
2224
</main>

examples/src/syncedExample.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { type PluginContext } from "@reearth/zushi";
2+
import { usePlugin } from "@reearth/zushi/react";
3+
import { useCallback, useRef } from "react";
4+
5+
import { backend, Log, useHostLog } from "./shared";
6+
7+
// The plugin's counter lives in the host-owned synced store, keyed "count".
8+
const syncedPluginSource = `
9+
const { useSyncedState, h, render } = zushi;
10+
function App() {
11+
const [n, setN] = useSyncedState("count", 0);
12+
return h("div", { style: { font: "14px sans-serif", padding: "8px", display: "flex", gap: "8px", alignItems: "center" } },
13+
h("button", { onClick: () => setN(n + 1) }, "plugin +1"),
14+
h("strong", null, "count: " + n)
15+
);
16+
}
17+
render(h(App));
18+
`;
19+
20+
// Synced state: the count is shared two-way with the host and persisted across
21+
// reloads. The plugin bumps it from inside; the host bumps it via plugin.synced.
22+
export function SyncedExample() {
23+
const { log, host } = useHostLog();
24+
const hostRef = useRef(host);
25+
hostRef.current = host;
26+
27+
const exposed = useCallback(
28+
(_ctx: PluginContext) => ({
29+
host: {
30+
event: (name: string, value?: number) =>
31+
hostRef.current.event(name, value)
32+
}
33+
}),
34+
[]
35+
);
36+
37+
const { containerRef, getPlugin } = usePlugin({
38+
backend,
39+
jsx: true,
40+
synced: { persist: true, instanceId: "synced-demo" }, // survives reload
41+
autoResize: "both",
42+
code: syncedPluginSource,
43+
exposed
44+
});
45+
46+
const bumpFromHost = () => {
47+
const synced = getPlugin()?.synced;
48+
if (!synced) return;
49+
const next = ((synced.get("count") as number) ?? 0) + 1;
50+
synced.set("count", next); // → the plugin re-renders with the new value
51+
hostRef.current.event("host set count", next);
52+
};
53+
54+
return (
55+
<section className="card">
56+
<h2>Synced · useSyncedState</h2>
57+
<div ref={containerRef} className="frame" />
58+
<button onClick={bumpFromHost}>host +1</button>
59+
<p style={{ fontSize: 12, opacity: 0.7, margin: "6px 0 0" }}>
60+
Shared two-way with the host, and persisted — reload and the count stays.
61+
</p>
62+
<Log entries={log} />
63+
</section>
64+
);
65+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export {
1717
reactRenderer,
1818
hostReactRenderer,
1919
isHostRenderer,
20+
SyncedStore,
21+
type SyncedStoreOptions,
22+
type SyncedMap,
2023
ROOT_ID,
2124
MSG_RENDER,
2225
MSG_EVENT,

0 commit comments

Comments
 (0)