Skip to content

Commit 3cd8de1

Browse files
serpentbladeclaude
andcommitted
docs(45-10): document the $clone sigil in the public docs site
- Add a `$clone(x)` section to docs/guide/features.md after $snapshot: worked undo/redo example, the Vue/Svelte structuredClone footgun, per-target lowering table, $snapshot-vs-$clone comparison (two-way cross-links), serializable-only caveats, ROZ135/136 diagnostics - Add a $clone parity row to docs/compatibility.md (full parity, all six) - Docs-only: no emitter/source change; dist-parity untouched Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 807a890 commit 3cd8de1

2 files changed

Lines changed: 80 additions & 0 deletions

File tree

docs/compatibility.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ For the narrative behind each ⚠︎, follow the link to the matching section in
6060
| `$emit(name, …)` |||||||
6161
| `$refs.name` from `ref="name"` |||||||
6262
| `$snapshot(x)` — crossing into untyped JS |||||||
63+
| `$clone(x)` — independent deep copy of reactive state |||||||
6364
| `$onMount` / `$onUnmount` ||||| [⚠︎](/parity#lit-solid-—-lifecycle-hooks-colocated-with-an-always-rendered-component) | [⚠︎](/parity#lit-solid-—-lifecycle-hooks-colocated-with-an-always-rendered-component) |
6465
| `$expose({ … })` — consumer-callable imperative handle |||||||
6566

docs/guide/features.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,85 @@ Reach for `$snapshot()` **only** when you're handing a reactive value to library
768768
If you skip it where you do need it, you'll see the Svelte runtime error [`state_descriptors_fixed`](https://svelte.dev/e/state_descriptors_fixed) the first time the library tries to mutate the value.
769769
:::
770770

771+
::: tip Need an independent copy, not an unwrap?
772+
`$snapshot()` is an **unwrap**, not a copy — on the five non-Svelte targets it hands back the same value you passed in. To freeze the current state so a later mutation can't reach back into it (undo/redo history, scratch snapshots), reach for [`$clone()`](#clone-x-—-an-independent-deep-copy-of-reactive-state) below instead.
773+
:::
774+
775+
## `$clone(x)` — an independent deep copy of reactive state
776+
777+
`$clone(x)` produces an **independent, deeply-copied** snapshot of a reactive value — safe to take on a `reactive()` / `$state` / signal-backed object on **every** target. It is the right primitive whenever you need to *freeze the current state* so that a later mutation of the live state doesn't reach back into the copy you stashed: undo/redo history stacks, cross-render scratch snapshots, "remember what this looked like before the drag."
778+
779+
```rozie
780+
<data>{ graph: { nodes: [], connections: [] }, history: [] }</data>
781+
782+
<script>
783+
const currentGraph = $computed(() => $data.graph)
784+
785+
// Before mutating the live graph (e.g. on drag-start), push a frozen,
786+
// independent copy onto the undo stack. A later edit to $data.graph
787+
// can't reach back and corrupt this history entry.
788+
const pushUndo = () => {
789+
$data.history = [...$data.history, $clone(currentGraph())]
790+
}
791+
792+
const undo = () => {
793+
const prev = $data.history.at(-1)
794+
if (prev) $data.graph = prev // the frozen copy, untouched by edits since
795+
}
796+
</script>
797+
```
798+
799+
### The footgun it closes
800+
801+
The naive way to take that snapshot is `structuredClone(x)` — and it works on React, Solid, Angular, and Lit, where reads return plain JS values. But a bare `structuredClone(<reactive value>)` **throws** (`DataCloneError: … could not be cloned`) on a Vue `reactive()` Proxy and a Svelte 5 `$state` Proxy. The result is a brutally **target-asymmetric** trap: your history stack fills correctly on four targets and is silently empty (or the component crashes) on Vue and Svelte only — the two targets a Vue-flavored author is least expecting to break.
802+
803+
`$clone` exists to erase that asymmetry. One author-side call lowers to the right deep-copy primitive on each target, so it produces an independent copy everywhere:
804+
805+
| Target | Expansion |
806+
| --- | --- |
807+
| Vue | `rozieDeepClone(x)` — from `@rozie/runtime-vue`; a recursive proxy-safe `structuredClone(deepToRaw(x))` that de-proxies **nested** `reactive()`/`ref` values, not just a top-level `reactive()` tree |
808+
| Svelte 5 | `$state.snapshot(x)` — Svelte's native recursive de-proxy + deep clone |
809+
| React | `structuredClone(x)` |
810+
| Solid | `structuredClone(x)` |
811+
| Angular | `structuredClone(x)` |
812+
| Lit | `structuredClone(x)` |
813+
814+
Because the copy goes through the structured-clone algorithm (not lossy `JSON.parse(JSON.stringify(x))`), it preserves `Date`, `Map`, and `Set` rather than mangling them to ISO strings and `{}`. `$clone(null)` returns `null` on all six.
815+
816+
::: warning Why a single `toRaw` isn't enough on Vue
817+
The Vue lowering deliberately uses a **recursive** de-proxy (`rozieDeepClone`), not `structuredClone(toRaw(x))`. A single top-level `toRaw` unwraps only the outermost `reactive()` tree — a *nested* independent reactive proxy or `ref` (e.g. an array of reactive items, or `$clone({ d: src.data })` where `src.data` is itself a live proxy) stays live, and `structuredClone` rejects it one level down. `rozieDeepClone` walks the whole structure (WeakMap-guarded against cycles) so Vue reaches true parity with Svelte's recursive `$state.snapshot`.
818+
819+
A Vue leaf that uses `$clone` therefore needs `@rozie/runtime-vue` in its package `dependencies` — it's the one extra peer the sigil pulls in on the Vue target.
820+
:::
821+
822+
### `$clone` vs `$snapshot` — pick the right one
823+
824+
These two sigils look similar and are easy to confuse, but they answer different questions:
825+
826+
| | [`$snapshot(x)`](#snapshot-—-crossing-into-untyped-js) | `$clone(x)` |
827+
| --- | --- | --- |
828+
| **What it does** | **Unwraps** a reactive value to a plain one | Produces an **independent deep copy** |
829+
| **On the 5 non-Svelte targets** | Identity passthrough — **same object back** | A real, separate copy every time |
830+
| **Reach for it when** | Handing a value to library code that mutates property descriptors (Chart.js `Object.defineProperty`) | Freezing state for history/undo/scratch — you must keep a copy that later edits can't touch |
831+
| **Independent copy guaranteed?** | No (only on Svelte) | Yes, on all six |
832+
833+
If you take a "snapshot" for an undo stack with `$snapshot()` and your target happens to be React/Vue/Solid/Angular/Lit, you've stashed a **live reference** — the next edit mutates your "history" in place. Use `$clone()` for anything you intend to keep frozen.
834+
835+
### Caveats — serializable state only
836+
837+
`$clone` rides the structured-clone algorithm, so it carries that algorithm's one hard limit: **a value containing a function or a DOM node throws** (`DataCloneError`). Clone serializable state — graph data, plain config, history snapshots — not live handles, callbacks, or element references. This throw is an author error surfaced loudly, not a silent corruption.
838+
839+
The ROZ135 steer (below) is intentionally **narrow**: it flags a *direct* `structuredClone($props/$data/$model.member)` and a single **one-hop** const alias (`const g = $data.graph; structuredClone(g)`). Two-hop chains, values passed through a parameter, and values returned from a call are **not** caught — so the absence of a warning is not a guarantee that a given `structuredClone` is safe. When in doubt on a reactive value, prefer `$clone`.
840+
841+
### Diagnostics
842+
843+
| Code | Severity | When |
844+
| --- | --- | --- |
845+
| `ROZ135` `STRUCTURED_CLONE_REACTIVE` | warning | A bare `structuredClone(<reactive member or one-hop alias>)` — steers you to `$clone(x)`, which is safe on Vue/Svelte where the raw call throws |
846+
| `ROZ136` `CLONE_BAD_ARITY` | error | `$clone` called with anything but exactly one non-spread argument (`$clone()`, `$clone(a, b)`, `$clone(...x)`) — the per-target lowering hard-codes a single argument |
847+
848+
Naming a `<data>` field or `r-for` loop variable `$clone` collides with the reserved sigil (`ROZ202`). See the [Diagnostics reference](/reference/diagnostics) for the full code table.
849+
771850
## Safe non-primitive interpolation — objects render as portable JSON, never crash
772851

773852
Interpolate a non-primitive value — an array, a plain object, a reactive `$data` graph — and the six targets used to disagree wildly. Vue pretty-printed JSON (its native `toDisplayString`), Svelte and Angular showed comma-joined `[object Object]`, Solid and Lit showed space-joined `[object Object]`, and **React threw `Objects are not valid as a React child` and crashed the component.** Same source, six renderings, one hard crash.

0 commit comments

Comments
 (0)