diff --git a/.eslintignore b/.eslintignore
index 9220f74..8214920 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,5 +1,6 @@
/dist
_unstable_apply.js
+_unstable_machine.js
_unstable_store.js
path.js
.eslintrc.cjs
diff --git a/.github/renovate.json b/.github/renovate.json
index 59b43dd..dd88efd 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -1,4 +1,12 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": ["local>sanity-io/renovate-config"]
+ "extends": ["local>sanity-io/renovate-config"],
+ "packageRules": [
+ {
+ "matchDepNames": ["xstate"],
+ "matchDepTypes": ["devDependencies", "peerDependencies"],
+ "rangeStrategy": "bump",
+ "semanticCommitType": "fix"
+ }
+ ]
}
diff --git a/.gitignore b/.gitignore
index 3e91e3b..d8c42d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ coverage
# legacy exports
_unstable_apply.js
+_unstable_machine.js
_unstable_store.js
path.js
tsconfig.tsbuildinfo
diff --git a/examples/visual-editing/.env.example b/examples/visual-editing/.env.example
new file mode 100644
index 0000000..c19fa1a
--- /dev/null
+++ b/examples/visual-editing/.env.example
@@ -0,0 +1,3 @@
+VITE_SANITY_API_TOKEN=
+VITE_SANITY_API_PROJECT_ID=
+VITE_SANITY_API_DATASET=
diff --git a/examples/visual-editing/.eslintrc.cjs b/examples/visual-editing/.eslintrc.cjs
new file mode 100644
index 0000000..ce1c34c
--- /dev/null
+++ b/examples/visual-editing/.eslintrc.cjs
@@ -0,0 +1,53 @@
+module.exports = {
+ extends: '../../.eslintrc.cjs',
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ },
+ overrides: [
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: true,
+ tsconfigRootDir: `${__dirname}`,
+ },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:prettier/recommended',
+ 'plugin:@typescript-eslint/eslint-recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ 'plugin:react/jsx-runtime',
+ ],
+ plugins: ['import', '@typescript-eslint', 'prettier', 'react-compiler'],
+ rules: {
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/interface-name-prefix': 'off',
+ '@typescript-eslint/member-delimiter-style': 'off',
+ '@typescript-eslint/no-empty-interface': 'off',
+ '@typescript-eslint/no-unused-vars': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/consistent-type-imports': [
+ 'error',
+ {prefer: 'type-imports'},
+ ],
+ '@typescript-eslint/no-dupe-class-members': ['error'],
+ '@typescript-eslint/no-shadow': ['error'],
+ 'unused-imports/no-unused-imports': 'error',
+ 'unused-imports/no-unused-vars': ['warn'],
+ 'import/no-duplicates': ['error', {'prefer-inline': true}],
+ 'import/first': 'error',
+ 'import/newline-after-import': 'error',
+ 'import/consistent-type-specifier-style': ['error', 'prefer-inline'],
+ 'import/order': 'off', // handled by simple-import-sort
+ 'sort-imports': 'off', // handled by simple-import-sort
+ 'simple-import-sort/imports': 'error',
+ 'simple-import-sort/exports': 'error',
+ 'react-compiler/react-compiler': 'error',
+ },
+ },
+ ],
+}
diff --git a/examples/visual-editing/.gitignore b/examples/visual-editing/.gitignore
new file mode 100644
index 0000000..9488683
--- /dev/null
+++ b/examples/visual-editing/.gitignore
@@ -0,0 +1,3 @@
+.env
+.vercel
+.env*.local
diff --git a/examples/visual-editing/index.html b/examples/visual-editing/index.html
new file mode 100644
index 0000000..2fa3862
--- /dev/null
+++ b/examples/visual-editing/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Sanity Visual Editing example
+
+
+
+
+
+
diff --git a/examples/visual-editing/main.tsx b/examples/visual-editing/main.tsx
new file mode 100644
index 0000000..e4d8191
--- /dev/null
+++ b/examples/visual-editing/main.tsx
@@ -0,0 +1,81 @@
+import {studioTheme, ThemeProvider} from '@sanity/ui'
+import {lazy, Suspense, useState, useSyncExternalStore} from 'react'
+import {createRoot} from 'react-dom/client'
+import {createGlobalStyle} from 'styled-components'
+
+const Studio = lazy(() => import('./studio'))
+const Preview = lazy(() => import('./preview'))
+
+const SCROLLBAR_SIZE = 12 // px
+const SCROLLBAR_BORDER_SIZE = 4 // px
+
+const GlobalStyle = createGlobalStyle`
+ html, body {
+ margin: 0;
+ padding: 0;
+ overscroll-behavior: none;
+ }
+
+ body {
+ scrollbar-gutter: stable;
+ }
+
+ ::-webkit-scrollbar {
+ width: ${SCROLLBAR_SIZE}px;
+ height: ${SCROLLBAR_SIZE}px;
+ }
+
+ ::-webkit-scrollbar-corner {
+ background-color: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-clip: content-box;
+ background-color: var(--card-border-color, ${({theme}) => theme.sanity.color.border});
+ border: ${SCROLLBAR_BORDER_SIZE}px solid transparent;
+ border-radius: ${SCROLLBAR_SIZE * 2}px;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: var(--card-muted-fg-color, ${({theme}) => theme.sanity.color.muted.fg});
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+`
+
+createRoot(document.getElementById('root') as HTMLElement).render(
+
+
+
+ ,
+)
+
+function Router() {
+ const [isPreview] = useState(() => {
+ const {searchParams} = new URL(location.href)
+ return searchParams.has('preview')
+ })
+ const [isDebug] = useState(() => {
+ const {searchParams} = new URL(location.href)
+ return searchParams.has('debug')
+ })
+ // If we're being inside an iframe, or spawned from another window, render the preview
+ const isStudio = useSyncExternalStore(
+ subscribe,
+ () => window.self === window.top && !window.opener,
+ )
+
+ return (
+
+ {!isPreview && isStudio ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+const subscribe = () => () => {}
diff --git a/examples/visual-editing/package.json b/examples/visual-editing/package.json
new file mode 100644
index 0000000..0b7ebb7
--- /dev/null
+++ b/examples/visual-editing/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "example-visual-editing",
+ "version": "1.0.0",
+ "private": true,
+ "license": "MIT",
+ "main": "package.json",
+ "scripts": {
+ "dev": "vite dev",
+ "start": "vite dev",
+ "test": "vitest"
+ },
+ "dependencies": {
+ "@react-spring/three": "^9.7.5",
+ "@react-three/drei": "^9.117.3",
+ "@react-three/fiber": "^8.17.10",
+ "@sanity/client": "^6.22.5",
+ "@sanity/comlink": "^2.0.0",
+ "@sanity/icons": "^3.5.0",
+ "@sanity/mutate": "workspace:*",
+ "@sanity/sanitype": "^0.6.2",
+ "@sanity/ui": "^2.8.9",
+ "@statelyai/inspect": "^0.4.0",
+ "@types/three": "^0.169.0",
+ "@xstate/react": "^4.1.3",
+ "comlink": "^4.4.1",
+ "groq-js": "^1.13.0",
+ "mendoza": "^3.0.7",
+ "nice-color-palettes": "^4.0.0",
+ "polished": "^4.3.1",
+ "react": "^18.3.1",
+ "react-colorful": "^5.6.1",
+ "react-compiler-runtime": "beta",
+ "react-dom": "^18.3.1",
+ "react-jason": "^1.1.2",
+ "rxjs": "^7.8.1",
+ "styled-components": "^6.1.13",
+ "three": "^0.169.0",
+ "xstate": "^5.18.2"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.1",
+ "@typescript-eslint/eslint-plugin": "catalog:",
+ "@typescript-eslint/parser": "catalog:",
+ "@vitejs/plugin-react": "^4.3.2",
+ "babel-plugin-react-compiler": "beta",
+ "eslint": "catalog:",
+ "eslint-config-prettier": "catalog:",
+ "eslint-config-sanity": "catalog:",
+ "eslint-plugin-import": "catalog:",
+ "eslint-plugin-prettier": "catalog:",
+ "eslint-plugin-react": "catalog:",
+ "eslint-plugin-react-compiler": "beta",
+ "eslint-plugin-react-hooks": "catalog:",
+ "eslint-plugin-simple-import-sort": "catalog:",
+ "eslint-plugin-unused-imports": "catalog:",
+ "typescript": "catalog:",
+ "vite": "^5.4.9",
+ "vitest": "^2.1.3"
+ }
+}
diff --git a/examples/visual-editing/preview/App.tsx b/examples/visual-editing/preview/App.tsx
new file mode 100644
index 0000000..91e972f
--- /dev/null
+++ b/examples/visual-editing/preview/App.tsx
@@ -0,0 +1,94 @@
+import {Box, Card, Grid} from '@sanity/ui'
+import {useRef, useState} from 'react'
+
+import {DOCUMENT_IDS} from '../shared/constants'
+import {useVisualEditingDocumentSnapshot} from './context'
+import EditableShoeComponent from './EditableShoe'
+import PreviewShoeComponent from './PreviewShoe'
+
+export default function App() {
+ const [editing, setEditing] = useState(DOCUMENT_IDS[0]!)
+ const scrollRef = useRef(0)
+
+ return (
+ <>
+ {
+ scrollRef.current = e.currentTarget.scrollLeft / 320
+ }}
+ >
+
+ {DOCUMENT_IDS.map((id, index) => (
+
+ ))}
+
+
+
+ >
+ )
+}
+
+function EditableShoe(props: {documentId: string}) {
+ const {documentId} = props
+ const snapshot = useVisualEditingDocumentSnapshot(documentId)
+
+ return (
+
+ )
+}
+
+function PreviewShoe(props: {
+ documentId: string
+ selected: boolean
+ setEditing: React.Dispatch>
+ scrollRef: React.MutableRefObject
+ index: number
+}) {
+ const {documentId, selected, setEditing, scrollRef, index} = props
+
+ const snapshot = useVisualEditingDocumentSnapshot(documentId) || ({} as any)
+ const {name, model} = snapshot
+
+ return (
+ setEditing(documentId)}
+ border={true}
+ >
+
+
+ )
+}
diff --git a/examples/visual-editing/preview/EditableShoe.tsx b/examples/visual-editing/preview/EditableShoe.tsx
new file mode 100644
index 0000000..1d0b988
--- /dev/null
+++ b/examples/visual-editing/preview/EditableShoe.tsx
@@ -0,0 +1,227 @@
+import {
+ ContactShadows,
+ Environment,
+ OrbitControls,
+ View,
+} from '@react-three/drei'
+import {useFrame} from '@react-three/fiber'
+import {type Infer} from '@sanity/sanitype'
+import {Box, Card, Heading, Inline} from '@sanity/ui'
+import {useEffect, useRef, useState} from 'react'
+import {HexColorPicker} from 'react-colorful'
+import {styled} from 'styled-components'
+import {type Group, type Object3DEventMap} from 'three'
+
+import {type airmax, type dunklow, type ultraboost} from '../studio/schema/shoe'
+import {AirmaxModel, DunklowModel, UltraboostModel} from './Shoes'
+
+const StyledView = styled(View)`
+ height: 100%;
+ width: 100%;
+`
+
+export default function EditableShoe(props: {
+ documentId: string
+ title: string | undefined
+ model: Infer
+}) {
+ const {title, documentId} = props
+ const ref = useRef(null)
+ const [selectedColor, setSelectedColor] = useState(null)
+
+ const [model, mutateModel] = useState(() => props.model as any)
+
+ const [syncedId, setSyncedId] = useState(() => props.documentId)
+ useEffect(() => {
+ if (documentId !== syncedId) {
+ mutateModel(props.model as any)
+ setSyncedId(documentId)
+ }
+ }, [documentId, props.model, syncedId])
+
+ const [hovered, setHovered] = useState(null)
+ useEffect(() => {
+ const el = ref.current
+ if (!hovered || !el) return
+
+ const cursor = ``
+ const auto = ``
+ el.style.cursor = `url('data:image/svg+xml;base64,${btoa(cursor)}'), auto`
+ return () => {
+ el.style.cursor = `url('data:image/svg+xml;base64,${btoa(auto)}'), auto`
+ }
+ }, [hovered, model])
+
+ return (
+
+
+
+ {props.model ? title || 'Untitled' : 'Loading…'}
+
+
+
+
+
+ (
+ e.stopPropagation(), setHovered(e.object.material.name)
+ )}
+ onPointerOut={e => e.intersections.length === 0 && setHovered(null)}
+ onPointerMissed={() => setSelectedColor(null)}
+ onClick={e => (
+ e.stopPropagation(), setSelectedColor(e.object.material.name)
+ )}
+ >
+ {model._type === 'airmax' ? (
+
+ ) : model._type === 'dunklow' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+function EditableAirmaxShoe(props: {model: Infer}) {
+ const {model} = props
+ const ref = useRef>(null)
+
+ useFrame(state => {
+ if (!ref.current) return
+ const t = state.clock.getElapsedTime()
+ ref.current.rotation.set(
+ Math.cos(t / 4) / 8,
+ -5 + Math.sin(t / 4) / 8,
+ (3 + Math.sin(t / 1.5)) / 20,
+ )
+ ref.current.position.y = (1 + Math.sin(t / 1.5)) / 10 - 0.5
+ })
+
+ return
+}
+
+function EditableDunklowShoe(props: {model: Infer}) {
+ const {model} = props
+ const ref = useRef>(null)
+
+ useFrame(state => {
+ if (!ref.current) return
+ const t = state.clock.getElapsedTime()
+ ref.current.rotation.set(
+ Math.cos(t / 4) / 8,
+ 3 + Math.sin(t / 4) / 8,
+ 0.2 - (1 + Math.sin(t / 1.5)) / 20,
+ )
+ ref.current.position.y = (1 + Math.sin(t / 1.5)) / 10 - 0.5
+ })
+
+ return
+}
+
+function EditableUltraboostShoe(props: {model: Infer}) {
+ const {model} = props
+ const ref = useRef>(null)
+
+ useFrame(state => {
+ if (!ref.current) return
+ const t = state.clock.getElapsedTime()
+ ref.current.rotation.set(
+ Math.cos(t / 4) / 8,
+ Math.sin(t / 4) / 8,
+ -0.2 - (1 + Math.sin(t / 1.5)) / 20,
+ )
+ ref.current.position.y = (1 + Math.sin(t / 1.5)) / 10
+ })
+
+ return
+}
+
+function Picker({
+ model,
+ selectedColor,
+ mutateModel,
+}: {
+ model: Infer
+ selectedColor: string | null
+ mutateModel: React.Dispatch<
+ React.SetStateAction<
+ Infer
+ >
+ >
+}) {
+ return (
+
+
+ mutateModel(prev => ({...prev, [selectedColor!]: color}))
+ }
+ />
+
+ {selectedColor}
+
+
+ )
+}
diff --git a/examples/visual-editing/preview/PreviewShoe.tsx b/examples/visual-editing/preview/PreviewShoe.tsx
new file mode 100644
index 0000000..bc4419b
--- /dev/null
+++ b/examples/visual-editing/preview/PreviewShoe.tsx
@@ -0,0 +1,160 @@
+import {
+ ContactShadows,
+ Environment,
+ PerspectiveCamera,
+ View,
+} from '@react-three/drei'
+import {useFrame} from '@react-three/fiber'
+import {type Infer} from '@sanity/sanitype'
+import {Box, Heading} from '@sanity/ui'
+import {useRef} from 'react'
+import {styled} from 'styled-components'
+import {type Group, type Object3DEventMap} from 'three'
+
+import {type airmax, type dunklow, type ultraboost} from '../studio/schema/shoe'
+import {AirmaxModel, DunklowModel, UltraboostModel} from './Shoes'
+
+const StyledView = styled(View)`
+ height: 100%;
+ width: 100%;
+`
+
+export default function PreviewShoe(
+ props: {
+ title: string | undefined
+ } & Pick<
+ | React.ComponentProps
+ | React.ComponentProps
+ | React.ComponentProps,
+ 'model' | 'scrollRef' | 'offsetLeft'
+ >,
+) {
+ const {title, scrollRef, model, offsetLeft} = props
+
+ return (
+ <>
+
+
+ {title || 'Untitled'}
+
+
+
+
+
+ {model._type === 'airmax' ? (
+
+ ) : model._type === 'dunklow' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+function PreviewAirmaxShoe(props: {
+ model: Infer
+ scrollRef: React.MutableRefObject
+ offsetLeft: number
+}) {
+ const {offsetLeft, scrollRef, model} = props
+ const ref = useRef>(null)
+
+ useFrame(state => {
+ if (!ref.current) return
+ const t = state.clock.getElapsedTime()
+ ref.current.rotation.set(
+ Math.cos(t / 4) / 8,
+ -5 + Math.sin(t / 4) / 8 + (offsetLeft + scrollRef.current * 3 - 1000),
+ (3 + Math.sin(t / 1.5)) / 20,
+ )
+ ref.current.position.y = (1 + Math.sin((t + offsetLeft) / 1.5)) / 10 - 0.5
+ })
+
+ return
+}
+
+function PreviewDunklowShoe(props: {
+ model: Infer
+ scrollRef: React.MutableRefObject
+ offsetLeft: number
+}) {
+ const {offsetLeft, scrollRef, model} = props
+ const ref = useRef>(null)
+ useFrame(state => {
+ if (!ref.current) return
+ const t = state.clock.getElapsedTime()
+ ref.current.rotation.set(
+ Math.cos(t / 4) / 8,
+ 3 + Math.sin(t / 4) / 8 + (offsetLeft + scrollRef.current * 3 - 1000),
+ 0.2 - (1 + Math.sin(t / 1.5)) / 20,
+ )
+ ref.current.position.y = (1 + Math.sin((t + offsetLeft) / 1.5)) / 10 - 0.5
+ })
+ return
+}
+
+function PreviewUltraboostShoe(props: {
+ model: Infer
+ scrollRef: React.MutableRefObject
+ offsetLeft: number
+}) {
+ const {offsetLeft, scrollRef, model} = props
+ const ref = useRef>(null)
+
+ useFrame(state => {
+ if (!ref.current) return
+ const t = state.clock.getElapsedTime()
+ ref.current.rotation.set(
+ Math.cos(t / 4) / 8,
+ Math.sin(t / 4) / 8 + (offsetLeft + scrollRef.current * 3 - 1000),
+ -0.2 - (1 + Math.sin(t / 1.5)) / 20,
+ )
+ ref.current.position.y = (1 + Math.sin((t + offsetLeft) / 1.5)) / 10
+ })
+
+ return
+}
diff --git a/examples/visual-editing/preview/Shoes.tsx b/examples/visual-editing/preview/Shoes.tsx
new file mode 100644
index 0000000..c40d536
--- /dev/null
+++ b/examples/visual-editing/preview/Shoes.tsx
@@ -0,0 +1,281 @@
+import {useGLTF} from '@react-three/drei'
+import {type Infer} from '@sanity/sanitype'
+import {forwardRef} from 'react'
+import {type Group, type Object3DEventMap} from 'three'
+
+import {type airmax, type dunklow, type ultraboost} from '../studio/schema/shoe'
+
+export const AirmaxModel = forwardRef<
+ Group,
+ {model: Infer}
+>(function AirmaxModel(props, forwardedRef) {
+ const {model} = props
+ const {nodes, materials} = useGLTF('/shoe-airmax.glb')
+
+ const color = materials.ASSET_MAT_MR?.clone()
+ const gel = materials.ASSET_MAT_MR?.clone()
+
+ return (
+
+
+
+
+ )
+})
+
+export const DunklowModel = forwardRef<
+ Group,
+ {model: Infer}
+>(function DunklowModel(props, forwardedRef) {
+ const {model} = props
+ const {nodes, materials} = useGLTF('/shoe-dunklow.glb')
+
+ const towel = materials['Fluffy White Towel']?.clone()
+ const neck = materials['Material.011']?.clone()
+ const soleTop = materials['Material.012']?.clone()
+ const soleBottom = materials['Material.013']?.clone()
+ const nikeLogo = materials['Material.001']?.clone()
+ const coatFront = materials['Material.002']?.clone()
+ const coatMiddle = materials['Material.002']?.clone()
+ const coatBack = materials['Material.002']?.clone()
+ const patch = materials['Material.006']?.clone()
+ const laces = materials['Material.003']?.clone()
+ const nikeText = materials.Material?.clone()
+ const inner = nodes.desighn_00?.material?.clone()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+})
+
+export const UltraboostModel = forwardRef<
+ Group,
+ {model: Infer}
+>(function UltraboostModel(props, forwardedRef) {
+ const {model} = props
+ const {nodes, materials} = useGLTF('/shoe-ultraboost.glb')
+
+ const laces = materials.laces?.clone()
+ const mesh = materials.mesh?.clone()
+ const caps = materials.caps?.clone()
+ const inner = materials.inner?.clone()
+ const sole = materials.sole?.clone()
+ const stripes = materials.stripes?.clone()
+ const band = materials.band?.clone()
+ const patch = materials.patch?.clone()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+})
diff --git a/examples/visual-editing/preview/context.ts b/examples/visual-editing/preview/context.ts
new file mode 100644
index 0000000..c38ccae
--- /dev/null
+++ b/examples/visual-editing/preview/context.ts
@@ -0,0 +1,76 @@
+import {createClient} from '@sanity/client'
+import {createActorContext, useSelector} from '@xstate/react'
+import {useEffect} from 'react'
+import {createEmptyActor} from 'xstate'
+
+import {defineLocalDocumentMachine} from '../shared/state/local-document-machine'
+import {defineRemoteDocumentMachine} from '../shared/state/remote-document-machine'
+import {
+ createSharedListener,
+ defineGetDocument,
+ defineRemoteEvents,
+} from '../shared/state/remote-events-machine'
+import {defineVisualEditingMachine} from '../shared/state/visual-editor-machine'
+
+const sanityClient = createClient({
+ projectId: import.meta.env.VITE_SANITY_API_PROJECT_ID,
+ dataset: import.meta.env.VITE_SANITY_API_DATASET,
+ apiVersion: '2023-10-27',
+ useCdn: false,
+ token: import.meta.env.VITE_SANITY_API_TOKEN,
+})
+
+/**
+ * How long the `client.listen()` EventSource is kept alive after the last subscriber unsubscribes, until it's possibly resubscribed
+ */
+const keepAlive = 1_000
+
+/**
+ * Dependencies for the Visual Editing machine
+ */
+const listener = createSharedListener(sanityClient)
+const remoteEvents = defineRemoteEvents({listener, keepAlive})
+const getDocument = defineGetDocument({client: sanityClient})
+const remoteDocumentMachine = defineRemoteDocumentMachine({
+ getDocument,
+ remoteEvents,
+})
+const localDocumentMachine = defineLocalDocumentMachine({remoteDocumentMachine})
+
+export const visualEditingMachine = defineVisualEditingMachine({
+ localDocumentMachine,
+})
+
+export const {
+ Provider: VisualEditingProvider,
+ useActorRef: useVisualEditingActorRef,
+ useSelector: useVisualEditingSelector,
+} = createActorContext(visualEditingMachine)
+
+// Used to handle cases where an actor isn't set yet
+const emptyLocalDocumentActor = createEmptyActor()
+
+export function useVisualEditingDocumentSnapshot(documentId: string) {
+ const visualEditingActorRef = useVisualEditingActorRef()
+
+ // Always load up whichever document a snapshot is being requested for
+ useEffect(() => {
+ // Due to React StrictMode, we schedule it to the next tick, and cancel it on unmount
+ const raf = requestAnimationFrame(() => {
+ visualEditingActorRef.send({type: 'listen', documentId})
+ })
+ return () => cancelAnimationFrame(raf)
+ }, [documentId, visualEditingActorRef])
+
+ // Get a ref to the local document machine
+ const localDocumentActorRef = useVisualEditingSelector(
+ snapshot =>
+ snapshot.context.documents[documentId] || emptyLocalDocumentActor,
+ )
+
+ return useSelector(
+ localDocumentActorRef,
+ // @ts-expect-error figure out how to infer types correctly when using nullable actor refs
+ state => state?.context?.remoteSnapshot,
+ )
+}
diff --git a/examples/visual-editing/preview/index.tsx b/examples/visual-editing/preview/index.tsx
new file mode 100644
index 0000000..f7f3b9d
--- /dev/null
+++ b/examples/visual-editing/preview/index.tsx
@@ -0,0 +1,90 @@
+import {useGLTF, View} from '@react-three/drei'
+import {Canvas} from '@react-three/fiber'
+import {Card, Grid} from '@sanity/ui'
+import {useEffect, useMemo, useRef, useState} from 'react'
+import {styled} from 'styled-components'
+
+// import * as Comlink from 'comlink'
+import {
+ globalSymbol,
+ type InspectorInspectContextValue,
+} from '../shared/inspector'
+import App from './App'
+import {VisualEditingProvider} from './context'
+
+useGLTF.preload([
+ '/shoe-airmax.glb',
+ '/shoe-dunklow.glb',
+ '/shoe-ultraboost.glb',
+])
+
+export default function Preview(props: {debug: boolean}) {
+ const {debug} = props
+ const eventSourceRef = useRef(null)
+ const [mounted, setMounted] = useState(!debug)
+ const [inspect, setInspect] =
+ useState(undefined)
+
+ // useEffect(() => {
+ // function send(event: unknown) {
+ // console.log('send', event)
+ // }
+ // Comlink.expose(send, Comlink.windowEndpoint(self.parent))
+ // }, [])
+
+ useEffect(() => {
+ if (mounted) return
+
+ const raf = requestAnimationFrame(() => {
+ setMounted(true)
+ // @ts-expect-error this is fine
+ const inspector = parent.window[globalSymbol]
+ if (inspector) setInspect(inspector.inspect)
+ })
+ return () => cancelAnimationFrame(raf)
+ }, [mounted])
+
+ const visualEditingOptions = useMemo(() => ({inspect}), [inspect])
+
+ return (
+ <>
+
+ {mounted && (
+
+
+
+
+
+ )}
+
+
+
+
+ >
+ )
+}
+
+const Container = styled(Grid)`
+ box-sizing: border-box;
+ height: 100vh;
+ max-height: 100dvh;
+ overflow: clip;
+ overscroll-behavior: none;
+ grid-auto-rows: min-content 1fr;
+`
+
+const StyledCanvas = styled(Canvas)`
+ pointer-events: none;
+ position: absolute !important;
+ top: 0.75rem;
+ left: 0.75rem;
+ bottom: 0.75rem;
+ right: 0.75rem;
+ height: auto !important;
+ width: auto !important;
+`
diff --git a/examples/visual-editing/public/shoe-airmax.glb b/examples/visual-editing/public/shoe-airmax.glb
new file mode 100644
index 0000000..c87d00d
Binary files /dev/null and b/examples/visual-editing/public/shoe-airmax.glb differ
diff --git a/examples/visual-editing/public/shoe-dunklow.glb b/examples/visual-editing/public/shoe-dunklow.glb
new file mode 100644
index 0000000..66f5487
Binary files /dev/null and b/examples/visual-editing/public/shoe-dunklow.glb differ
diff --git a/examples/visual-editing/public/shoe-ultraboost.glb b/examples/visual-editing/public/shoe-ultraboost.glb
new file mode 100644
index 0000000..c600339
Binary files /dev/null and b/examples/visual-editing/public/shoe-ultraboost.glb differ
diff --git a/examples/visual-editing/shared/DocumentView.tsx b/examples/visual-editing/shared/DocumentView.tsx
new file mode 100644
index 0000000..5e9698c
--- /dev/null
+++ b/examples/visual-editing/shared/DocumentView.tsx
@@ -0,0 +1,84 @@
+import {type SanityDocumentBase} from '@sanity/mutate'
+import {
+ Card,
+ Flex,
+ Heading,
+ Stack,
+ Tab,
+ TabList,
+ TabPanel,
+ Text,
+} from '@sanity/ui'
+import {useState} from 'react'
+
+import {JsonView} from './json-view/JsonView'
+
+interface DocumentViewProps {
+ local: Doc | undefined
+ remote: Doc | undefined
+}
+
+const TABS = [{id: 'current', title: 'Current document'}] as const
+
+export function DocumentView(
+ props: DocumentViewProps,
+) {
+ const {local, remote} = props
+ const [tabId, setTab] = useState<(typeof TABS)[number]['id']>(TABS[0].id)
+ const tabs = {
+ current: () => (
+
+ {local && (
+
+
+ Local
+
+
+
+
+
+ )}
+ {remote && (
+
+
+ Remote
+
+
+
+
+
+ )}
+
+ ),
+ }
+ return (
+
+
+ {TABS.map(tab => (
+ {
+ setTab(tab.id)
+ }}
+ selected={tab.id === tabId}
+ />
+ ))}
+
+ {TABS.map(tab => (
+
+
+ {tabs[tab.id]()}
+
+
+ ))}
+
+ )
+}
diff --git a/examples/visual-editing/shared/constants.ts b/examples/visual-editing/shared/constants.ts
new file mode 100644
index 0000000..7adc9d2
--- /dev/null
+++ b/examples/visual-editing/shared/constants.ts
@@ -0,0 +1,10 @@
+export const DOCUMENT_IDS = [
+ 'shoe-a',
+ 'shoe-b',
+ 'shoe-c',
+ 'shoe-d',
+ 'shoe-e',
+ 'shoe-f',
+ // 'shoe-g',
+ // 'shoe-h',
+]
diff --git a/examples/visual-editing/shared/inspector.ts b/examples/visual-editing/shared/inspector.ts
new file mode 100644
index 0000000..bb06f31
--- /dev/null
+++ b/examples/visual-editing/shared/inspector.ts
@@ -0,0 +1,10 @@
+import {type InspectionEvent, type Observer} from 'xstate'
+
+export const globalSymbol: symbol = Symbol.for(
+ '@statelyai/inspect/example-visual-editing',
+)
+
+export type InspectorInspectContextValue =
+ | Observer
+ | ((inspectionEvent: InspectionEvent) => void)
+ | undefined
diff --git a/examples/visual-editing/shared/json-view/JsonView.tsx b/examples/visual-editing/shared/json-view/JsonView.tsx
new file mode 100644
index 0000000..4b0aa2b
--- /dev/null
+++ b/examples/visual-editing/shared/json-view/JsonView.tsx
@@ -0,0 +1,41 @@
+import {type CSSProperties} from 'react'
+import {ReactJason} from 'react-jason'
+import styled from 'styled-components'
+
+export const sharedRoot: CSSProperties = {
+ fontFeatureSettings: '"liga" 0, "calt" 0',
+ whiteSpace: 'pre',
+ margin: 0,
+}
+
+const theme = {
+ styles: {
+ root: sharedRoot,
+ attribute: {color: '#b8860b'},
+ unquotedAttribute: {color: '#b8860b'},
+ string: {color: '#008000'},
+ nil: {color: '#806600'},
+ number: {color: 'blue'},
+ boolean: {color: '#008080'},
+ punctuation: {color: '#888'},
+ },
+}
+
+const OnelineWrapper = styled.span`
+ pre {
+ white-space: normal !important;
+ display: inline;
+ }
+`
+
+export function JsonView(props: {value: any; oneline?: boolean}) {
+ const jason = (
+
+ )
+
+ return props.oneline ? {jason} : jason
+}
diff --git a/examples/visual-editing/shared/state/dataset-lru-cache-machine.ts b/examples/visual-editing/shared/state/dataset-lru-cache-machine.ts
new file mode 100644
index 0000000..1579d73
--- /dev/null
+++ b/examples/visual-editing/shared/state/dataset-lru-cache-machine.ts
@@ -0,0 +1,10 @@
+/**
+ * Synchronize a LRU Cache of a dataset with mendoza mutations.
+ * It uses a long lived subscription to a `client.listen()` instance, and will apply patches to it as they come in.
+ * If the LRU Cache starts evicting entries, then mendoza events will be ignored.
+ * Special considerations:
+ * - If a document exists initially, but then a mendonza event comes in that deletes it, then the cache entry for the document will be `null`.
+ * - A document that initially exists, is deleted, might then be created again, so documents that are `null` need to handle document creations.
+ * - Other machines might be writing to the same cache, so it's important to verify that the revision for the mendoza event isn't already applied before applying.
+ * - When checking incoming mendoza events to see if they should be applied it's important to use cache methods that don't increase the LRU Cache's `used` count, as this machine is supposed to sync documents in the background and its activities are not an indication of wether the document is recently used or not.
+ */
diff --git a/examples/visual-editing/shared/state/document-editor-machine.ts b/examples/visual-editing/shared/state/document-editor-machine.ts
new file mode 100644
index 0000000..5b135ac
--- /dev/null
+++ b/examples/visual-editing/shared/state/document-editor-machine.ts
@@ -0,0 +1,439 @@
+/* eslint-disable no-console */
+import {type MutationEvent, type SanityDocument} from '@sanity/client'
+import {type Mutation, type SanityDocumentBase} from '@sanity/mutate'
+import {
+ applyMutations,
+ commit,
+ type MutationGroup,
+ rebase,
+ squashDMPStrings,
+ squashMutationGroups,
+ toTransactions,
+} from '@sanity/mutate/_unstable_machine'
+import {applyPatch, type RawPatch} from 'mendoza'
+import {assertEvent, assign, setup} from 'xstate'
+
+import {
+ type GetDocumentMachine,
+ type RemoteEventsMachine,
+ type RemoteSnapshotEvents,
+} from './remote-events-machine'
+import {type SubmitTransactionsMachine} from './submit-transactions-machine'
+
+/**
+ * This machine is responsible for keeping a snapshot of a document in sync with any remote changes, as well as applying optimistic mutations, reconciling local and remote updates, and optimizing mutations before sending them in a transaction.
+ * It will setup a long lived subscription to a `client.listen()` instance.
+ * It's recommended that the `remoteEvents` machine is using a RxJS observer that can ensure that a single EventSource
+ * is established and its events multicast to all listeners, instead of setting up multiple EventSources to the same dataset as that would be a waste of resources and network bandwidth,
+ * not to mention there's a technical limit to how many concurrent `client.listen()` instances that a dataset allows: https://www.sanity.io/docs/technical-limits#c973bb88d2be
+ * The connection is built on mendoza events which are efficient and low bandwidth.
+ * If a document doesn't exist yet, the `getDocument` actor won't return a result,
+ * but once a document is created it'll emit a mendoza event that will create the document without needing to refetch it.
+ * If the `getDocument` fetch itself fails, then it has automatic retries with exponential backoff, as well as the ability to manually fire a `retry` event.
+ *
+ * All in all there's a lot of edge cases and logic to consider when one wishes to have a snapshot of a remote document that is then kept in sync with remote changes.
+ * This machine handles all of those concerns, so that other machines only have to care about wether we have a snapshot of the remote or not, and to handle that accordingly.
+ */
+export function defineDocumentEditorMachine<
+ const DocumentType extends SanityDocumentBase = SanityDocumentBase,
+>({
+ getDocument,
+ remoteEvents,
+ submitTransaction,
+}: {
+ getDocument: GetDocumentMachine
+ remoteEvents: RemoteEventsMachine
+ submitTransaction: SubmitTransactionsMachine
+}) {
+ return setup({
+ types: {} as {
+ children: {
+ remoteEvents: 'remoteEvents'
+ getDocument: 'getDocument'
+ submitTransaction: 'submitTransaction'
+ }
+ context: {
+ /* The document id, matches `_id` on the document if it exists, and is immutable. If a different document should be edited, then another machine should be spawned for that document */
+ documentId: string
+ /* Preferrably a LRU cache map that is compatible with an ES6 Map, and have documents that allow unique ids to a particular dataset */
+ cache: Map | null>
+ /* The remote snapshot of what the document looks like in Content Lake, kept in sync by applying Mendoza patches in real time. undefined means it's unknown if it exists yet, null means its known that it doesn't exist. */
+ remote: SanityDocument | null | undefined
+ /* Local snapshot, that is rebased to the remote snapshot whenever that snapshot changes, and allows optimistic local mutations. undefined means it's unknown if the document exists in content lake yet, if both `remote` and `local` is `null` it means it's known that it doesn't exist. If `remote` is defined, and `local` is `null` it means it's optimistically deleted. If `remote` is `null` and `local` defined then it's optimistically created. */
+ local: SanityDocument | null | undefined
+ /* Remote mendoza mutation events, needs a better name to differentiate from optimistic mutations */
+ mutationEvents: MutationEvent[]
+ /* Track staged mutations that can be submitted */
+ stagedChanges: MutationGroup[]
+ /* Queue mutations mutations that should be staged after an ongoing submission settles */
+ stashedChanges: MutationGroup[]
+ error: unknown
+ attempts: number
+ }
+ events:
+ | RemoteSnapshotEvents
+ | {type: 'retry'}
+ | {type: 'submit'}
+ | {type: 'mutate'; mutations: Mutation[]}
+ | {type: 'success'}
+ // Rebases the local snapshot with the remote snapshot
+ | {type: 'rebase local to remote'}
+ // Build up list over changes, mutations and transactions we wish to submit later
+ | {type: 'stage changes'}
+ // Applies the event changes to the local document snapshot
+ | {type: 'optimistically mutate'}
+ | {type: 'stash changes'}
+ | {type: 'restore stashed changes'}
+ input: {
+ documentId: string
+ cache?: Map | null>
+ }
+ },
+ actors: {
+ getDocument,
+ remoteEvents,
+ submitTransaction,
+ },
+ actions: {
+ 'stage changes': assign({
+ stagedChanges: ({context, event}) => {
+ assertEvent(event, 'mutate')
+ const {mutations} = event
+
+ return [...context.stagedChanges, {transaction: false, mutations}]
+ },
+ }),
+ 'optimistically mutate': assign({
+ local: ({context, event}) => {
+ assertEvent(event, 'mutate')
+ const {mutations} = event
+
+ // @TODO perhaps separate utils to be lower level and operate on single documents at a time instead of expecting a local dataset
+ const localDataset = new Map()
+ localDataset.set(context.documentId, context.local)
+ // Apply mutations to local dataset (note: this is immutable, and doesn't change the dataset)
+ const results = applyMutations(mutations, localDataset)
+ // Write the updated results back to the "local" dataset
+ commit(results, localDataset)
+ return localDataset.get(context.documentId) || null
+ },
+ }),
+ 'stash changes': assign({
+ stashedChanges: ({context, event}) => {
+ assertEvent(event, 'mutate')
+ const {mutations} = event
+
+ return [...context.stashedChanges, {transaction: false, mutations}]
+ },
+ }),
+ 'restore changes': assign({
+ stagedChanges: ({context}) => {
+ return context.stashedChanges
+ },
+ stashedChanges: [],
+ }),
+ },
+ delays: {
+ // Exponential backoff delay function
+ timeout: ({context}) => Math.pow(2, context.attempts) * 1000,
+ },
+ guards: {
+ isCached: ({context}) => {
+ return context.cache.has(context.documentId)
+ },
+ },
+ }).createMachine({
+ /** @xstate-layout N4IgpgJg5mDOIC5QQPYGMCuBbMA7ALgLSQCW+KATgMQVhoq6534DaADALqKgAOKsZEg24gAHogAsAJgA0IAJ6IAjADYArADoAnAGZVEiTolqpOgBxszAXytzUmHAWIQylDSQgAbMFQDuYT3ocdi4kED4BfCFcEXEEKSkzDTYjFKU2NTYtMzNTOUUEFSkVDSUAdh0dKSULMyLjGzt0bDwiUnIKdy8ff0CUYKVQ3n5BYTC46Q1qqS0JFS0tYp0ylPzETM0tFUs1JRMdLK0MxpB7Fqd2t08UAEMXXCgqVCZ3XAA3FABrMA0YfAARZqOVicEQRUYxcaIMwSJSlKQSbISMqw6pZNbxBYaZYWMozVQqQkqMonM7A5yuTrXO4kB5UMAUChuHieG74ABmlCwvzAAKBrRCYJGUTGoDiVSkGjKZl2KiUiKkajUyIxCKSC22ZRqc3UyNJ-IuLg6Gmp90eWAw+DZ0UFYXBIshYsQeIk2LYczxCKUaK0GKUWw06j26VhRMJJNspwNbSNblo9EYzFpj16QTAtuGkWisXWOi0GjMKyk7syZTUBwkfvU2j0KgMRn2tX1DlaFON7JuJE8GFoNF5FHkGfCwuzUIQexKxj0WRR3vSswxejhiSqbBUMMba+b5xjlI0Ha7PZ8olgVvwPxu7PPFAAFFEcChLQBKJ7RttuA-d2hD+2jp3xTQLA9bIVm2ZUlUXHRNAkDIVCghYpAqMwtDUbdyUuKlbggSAqAtM8bVBO0R1FMRlDqOE2BWKDcjKODiVkBRlESDRdgWNj2IWaxIzJVsMJNLDIA0HgKBIU9aR8PC2XTQjMwhHNx3KJIjGWFCylonR6IxExXXURJvVQ7i3z401BJcCh8HkKhYAwAAjLAyB-YjHVI8c1HmFjdDKVT1M0xiEDcsoCyVTyDKaFtDT3EyIA0MyLNwy0pMcrMSLiJRlh0bR1y8tQ1LovEMQMNhtERLLQqjcLd2NKKNGsuyyCiOlnh+WkPm+GrbPs-AABUKBuXBYBuNAHSSuSxwnfM1E87y8oYgoZnVCp9LQ3jY0wu5BNqzqGseBkmU6Fk2U5ChuU2sger6gahoIoZh2S5y4mqddkjmRZKJ8sotOrd1tlDMNCWWiKqoE6LTvwbb4rPaSbt-FKyK8gt4LMKp3tmyR-WKrZCzKnjAauYH2rq-AADFOy-CSEvPEaHXk0wYWxSbpDevKCrlAtCSg36wwByq8fWkGOrIEnD17Wh8AHKm-xchItDhPRFWMXKNI+vzdldZUfoJP6bEjXAUGw+AwhxnmKCFO75MIGXtCVCxtiRrVjDMDELZrNQzBqFDVAJJRuffToPG8U3Rv-QhDCt1210sZZ5VdrTy2SBINb+iMwp3X3+JpB5A+psdMldWi5ml-04JVPyYKKmYZZnGW9BRbGjNWjR4wYJgrszoizbHYs4WRFCDloywkaKP0ERYusajYNh-UmyopB9vjPyPLPJYmVH4gkJJDH0QxjFpyw54bqKl9h8dJ5KUwDGLRXfLmpRu8WhEOM4-fIvx4TRIasAj-u5Q11dc-GavvlFWcc2AJ3dJrcMz8gZ8xiiQcyBRZLZ3-PKRCUpii20QjNT6SlgrLDrhVNO1VQbbS-vJcoiwpiZBLIA1eypApQWnmWKBvNsL80JkLMmpDO4wU0GWCeCt3paWQlMPEictbayAA */
+ id: 'document-editor',
+ context: ({input}) => ({
+ documentId: input.documentId,
+ // @TODO provide an LRU Cache here that is synced with `dataset-lru-cache-machine`
+ cache:
+ input.cache || new Map | null>(),
+ remote: undefined,
+ local: undefined,
+ mutationEvents: [],
+ stagedChanges: [],
+ stashedChanges: [],
+ error: undefined,
+ attempts: 0,
+ }),
+
+ invoke: {
+ src: 'remoteEvents',
+ // id: 'remoteEvents',
+ input: ({context}) => ({documentId: context.documentId}),
+ },
+
+ on: {
+ // Reconnect failures are even worse than regular failures
+ reconnect: '.reconnecting',
+ },
+
+ states: {
+ idle: {
+ on: {
+ welcome: [
+ {
+ guard: 'isCached',
+ target: 'loaded',
+ actions: assign({
+ remote: ({context}) => context.cache.get(context.documentId),
+ }),
+ },
+ {target: 'loading'},
+ ],
+ },
+ },
+
+ loading: {
+ on: {
+ // While the document is loading, buffer up mutation events so we can replay them after the document is fetched
+ mutation: {
+ actions: assign({
+ mutationEvents: ({event, context}) => [
+ ...context.mutationEvents,
+ event,
+ ],
+ error: undefined,
+ }),
+ },
+ },
+ invoke: {
+ src: 'getDocument',
+ id: 'getDocument',
+ input: ({context}) => ({documentId: context.documentId}),
+
+ onDone: {
+ target: 'loaded',
+ actions: assign(({event, context}) => {
+ const previousRemote = context.remote
+ let nextRemote = event.output
+
+ /**
+ * We assume all patches that happen while we're waiting for the document to resolve are already applied.
+ * But if we do see a patch that has the same revision as the document we just fetched, we should apply any patches following it
+ */
+ let seenCurrentRev = false
+ for (const patch of context.mutationEvents) {
+ if (!patch.effects?.apply || !patch.previousRev) continue
+ if (!seenCurrentRev && patch.previousRev === nextRemote?._rev) {
+ seenCurrentRev = true
+ }
+ if (seenCurrentRev) {
+ nextRemote = applyMendozaPatch(
+ // @ts-expect-error handle later
+ nextRemote,
+ patch.effects.apply,
+ patch.resultRev,
+ )
+ }
+ }
+
+ if (
+ // If the shared cache don't have the document already we can just set it
+ !context.cache.has(context.documentId) ||
+ // But when it's in the cache, make sure it's necessary to update it
+ context.cache.get(context.documentId)!._rev !== nextRemote?._rev
+ ) {
+ context.cache.set(
+ context.documentId,
+ nextRemote as unknown as any,
+ )
+ }
+
+ const [stagedChanges, local] = rebase(
+ context.documentId,
+ // It's annoying to convert between null and undefined, reach consensus
+ previousRemote === null ? undefined : previousRemote,
+ nextRemote === null
+ ? undefined
+ : (nextRemote as unknown as any),
+ context.stagedChanges,
+ )
+
+ return {
+ remote: nextRemote as unknown as any,
+ local: local as unknown as any,
+ stagedChanges,
+ // Since the snapshot handler applies all the patches they are no longer needed, allow GC
+ mutationEvents: [],
+ }
+ }),
+ },
+
+ onError: {
+ target: 'failure',
+ actions: assign({
+ error: ({event}) => event.error,
+ // If the document fetch fails then we can assume any mendoza patches we buffered up will no longer be needed
+ mutationEvents: [],
+ }),
+ },
+ },
+ },
+
+ reconnecting: {
+ on: {
+ welcome: 'loading',
+ },
+ // A reconnection event is considered to be a catastrophic failure, and we have to reset remote state
+ // We don't reset local state, such as stagedChanges, the local snapshot, as optimistic mutations are still valid and some edits should be fine to do until we're back online
+ entry: [
+ assign({
+ remote: null,
+ mutationEvents: [],
+ }),
+ ],
+ },
+
+ failure: {
+ after: {
+ timeout: {
+ actions: assign({attempts: ({context}) => context.attempts + 1}),
+ target: 'loading',
+ },
+ },
+ on: {
+ // We can also manually retry
+ retry: 'loading',
+ },
+ },
+
+ loaded: {
+ on: {
+ // After the document has loaded, apply the mendoza patches directly
+ mutation: {
+ actions: assign(({context, event}) => {
+ const previousRemote = context.remote
+
+ // @TODO read from shared cache and check if it's necessary to apply mendoza
+ const nextRemote = event.effects?.apply
+ ? applyMendozaPatch(
+ // @ts-expect-error handle later
+ context.remote,
+ event.effects.apply,
+ event.resultRev,
+ )
+ : context.remote
+
+ const [stagedChanges, local] = rebase(
+ context.documentId,
+ // It's annoying to convert between null and undefined, reach consensus
+ previousRemote === null ? undefined : previousRemote,
+ nextRemote === null
+ ? undefined
+ : (nextRemote as unknown as any),
+ context.stagedChanges,
+ )
+
+ return {
+ remote: nextRemote as unknown as any,
+ local: local as unknown as any,
+ stagedChanges,
+ }
+ }),
+ },
+ },
+
+ initial: 'pristine',
+ states: {
+ pristine: {
+ on: {
+ mutate: {
+ actions: ['optimistically mutate', 'stage changes'],
+ target: 'dirty',
+ },
+ },
+ },
+ dirty: {
+ on: {
+ submit: 'submitting',
+ mutate: {
+ actions: ['optimistically mutate', 'stage changes'],
+ },
+ },
+ },
+ submitting: {
+ invoke: {
+ src: 'submitTransaction',
+ id: 'submitTransaction',
+ input: ({context}) => {
+ // @TODO perhaps separate utils to be lower level and operate on single documents at a time instead of expecting a local dataset
+ const remoteDataset = new Map()
+ remoteDataset.set(context.documentId, context.remote)
+ return {
+ transactions: toTransactions(
+ // Squashing DMP strings is the last thing we do before submitting
+ squashDMPStrings(
+ remoteDataset,
+ squashMutationGroups(context.stagedChanges),
+ ),
+ ),
+ }
+ },
+
+ onDone: {
+ target: 'pristine',
+ actions: ['restore changes'],
+ },
+
+ onError: {
+ target: 'submitFailure',
+ actions: assign({
+ error: ({event}) => event.error,
+ // @TODO handle restoring stuff?
+ }),
+ },
+ },
+
+ on: {
+ mutate: {
+ actions: ['optimistically mutate', 'stash changes'],
+ },
+ },
+ },
+ submitFailure: {
+ on: {
+ mutate: {
+ actions: ['optimistically mutate', 'stash changes'],
+ },
+ retry: 'submitting',
+ },
+ },
+ },
+ },
+ },
+
+ initial: 'idle',
+ })
+}
+
+export type DocumentEditorMachine<
+ DocumentType extends SanityDocumentBase = SanityDocumentBase,
+> = ReturnType>
+
+export function applyMendozaPatch<
+ const DocumentType extends SanityDocumentBase,
+>(
+ document: DocumentType | undefined,
+ patch: RawPatch,
+ nextRevision: string | undefined,
+) {
+ const next = applyPatch(omitRev(document), patch)
+ if (!next) {
+ return null
+ }
+ return Object.assign(next, {_rev: nextRevision})
+}
+
+function omitRev(
+ document: DocumentType | undefined,
+) {
+ if (!document) {
+ return null
+ }
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ const {_rev, ...doc} = document
+ return doc
+}
diff --git a/examples/visual-editing/shared/state/local-document-machine.ts b/examples/visual-editing/shared/state/local-document-machine.ts
new file mode 100644
index 0000000..6733d45
--- /dev/null
+++ b/examples/visual-editing/shared/state/local-document-machine.ts
@@ -0,0 +1,76 @@
+import {type SanityDocument} from '@sanity/client'
+import {type SanityDocumentBase} from '@sanity/mutate'
+import {assign, setup} from 'xstate'
+
+import {type RemoteDocumentMachine} from './remote-document-machine'
+
+/**
+ * This machine handles the tricky implementation details of applying patches optimistically,
+ * optimizing patches to create smaller deltas and payloads, and such.
+ */
+export function defineLocalDocumentMachine<
+ const DocumentType extends SanityDocumentBase = SanityDocumentBase,
+>({
+ remoteDocumentMachine,
+}: {
+ remoteDocumentMachine: RemoteDocumentMachine
+}) {
+ return setup({
+ types: {} as {
+ children: {
+ remoteDocumentMachine: 'remoteDocumentMachine'
+ }
+ context: {
+ documentId: string
+ localSnapshot: SanityDocument | null
+ remoteSnapshot: SanityDocument | null
+ }
+ events: {type: 'mutate'} | {type: 'commit'}
+ input: {
+ documentId: string
+ }
+ },
+ actors: {
+ remoteDocumentMachine,
+ },
+ }).createMachine({
+ /** @xstate-layout N4IgpgJg5mDOIC5QBsD2BjAhsgIhgrgLZgB2ALgMTICWsZpA2gAwC6ioADqrNWdaiXYgAHogC0ADgBMAOgkA2ACyKArBICcTdfKXSANCACeiFQHYZpgMxWV800yYqp6gIyn5AXw8G0WXAWJyCnwSGjpGViEuHj4BIVEEKUt5OSlFU1smFwkFW0sDY0T1GRUXRXkXKVN7HSYJJkUvHwxsPHQiUjIZDgAnWj4SMApCfDJMemY2JBBo3n5BaYSXeVlbKRUsiXdFSxcXfKNERSqZTbr1zVMJNyaQX1aAzpkIah6yQwp0VEJCXkmo7hzOKLRDLFwyFxaSzXKTOKQudSKCQFRDOczqCSWJjwjbHCQqRFebwgEioCBwIT3fztQJkAExebxcQuFEIZaWGRIrF7dSXKTyFSNYlUtodcjdPp0aiDelAhagBLpVmWAmc46mNwSZTyLQuFS3EWPcUvN6FTiA2LykSoq6c6wbCqVUzparKioyRFVfkq1zqNSWIkeIA */
+ id: 'localDocument',
+ context: ({input}) => ({
+ documentId: input.documentId,
+ localSnapshot: null,
+ remoteSnapshot: null,
+ }),
+
+ invoke: {
+ src: 'remoteDocumentMachine',
+ id: 'remoteDocumentMachine',
+ input: ({context}) => ({documentId: context.documentId}),
+ onSnapshot: {
+ actions: assign({
+ remoteSnapshot: ({event}) => event.snapshot.context.snapshot,
+ }),
+ },
+ },
+
+ initial: 'pristine',
+
+ states: {
+ pristine: {
+ on: {
+ mutate: 'dirty',
+ },
+ },
+
+ dirty: {
+ on: {
+ commit: 'pristine',
+ },
+ },
+ },
+ })
+}
+
+export type LocalDocumentMachine<
+ DocumentType extends SanityDocumentBase = SanityDocumentBase,
+> = ReturnType>
diff --git a/examples/visual-editing/shared/state/remote-document-machine.ts b/examples/visual-editing/shared/state/remote-document-machine.ts
new file mode 100644
index 0000000..aedfca7
--- /dev/null
+++ b/examples/visual-editing/shared/state/remote-document-machine.ts
@@ -0,0 +1,228 @@
+/* eslint-disable no-console */
+import {type MutationEvent, type SanityDocument} from '@sanity/client'
+import {type SanityDocumentBase} from '@sanity/mutate'
+import {applyPatch, type RawPatch} from 'mendoza'
+import {assign, setup} from 'xstate'
+
+import {
+ type GetDocumentMachine,
+ type RemoteEventsMachine,
+ type RemoteSnapshotEvents,
+} from './remote-events-machine'
+
+/**
+ * This machine is responsible for keeping a snapshot of a document in sync with any remote changes.
+ * It will setup a long lived subscription to a `client.listen()` instance.
+ * It's recommended that the `remoteEvents` machine is using a RxJS observer that can ensure that a single EventSource
+ * is established and its events multicast to all listeners, instead of setting up multiple EventSources to the same dataset as that would be a waste of resources and network bandwidth,
+ * not to mention there's a technical limit to how many concurrent `client.listen()` instances that a dataset allows: https://www.sanity.io/docs/technical-limits#c973bb88d2be
+ * The connection is built on mendoza events which are efficient and low bandwidth.
+ * If a document doesn't exist yet, the `getDocument` actor won't return a result,
+ * but once a document is created it'll emit a mendoza event that will create the document without needing to refetch it.
+ * If the `getDocument` fetch itself fails, then it has automatic retries with exponential backoff, as well as the ability to manually fire a `retry` event.
+ *
+ * All in all there's a lot of edge cases and logic to consider when one wishes to have a snapshot of a remote document that is then kept in sync with remote changes.
+ * This machine handles all of those concerns, so that other machines only have to care about wether we have a snapshot of the remote or not, and to handle that accordingly.
+ */
+export function defineRemoteDocumentMachine<
+ const DocumentType extends SanityDocumentBase = SanityDocumentBase,
+>({
+ getDocument,
+ remoteEvents,
+}: {
+ getDocument: GetDocumentMachine
+ remoteEvents: RemoteEventsMachine
+}) {
+ return setup({
+ types: {} as {
+ children: {
+ remoteEvents: 'remoteEvents'
+ getDocument: 'getDocument'
+ }
+ context: {
+ documentId: string
+ snapshot: SanityDocument | null
+ mutationEvents: MutationEvent[]
+ error: unknown
+ attempts: number
+ }
+ events: RemoteSnapshotEvents | {type: 'retry'}
+ input: {
+ documentId: string
+ }
+ },
+ actors: {
+ getDocument,
+ remoteEvents,
+ },
+ delays: {
+ // Exponential backoff delay function
+ timeout: ({context}) => Math.pow(2, context.attempts) * 1000,
+ },
+ }).createMachine({
+ /** @xstate-layout N4IgpgJg5mDOIC5QCcwFsD2AXMARDAxgK5pgB2WAxKgRmWWAVgNoAMAuoqAA4awCWWfnS4gAHogAsAJgA0IAJ6IAjAA4A7ADoAnLt3T1kgKxHtANiMBmAL7X5qTDnzFSFTfwgAbMJQDuYT1pSNk4kEF4BIREwiQRLA01WVVNldVTLVWldM3klBGVpVU0jVlLWSWVJdXLWbVt7dGw8QhJyLE1PDABDCH4yKEoIOjB3MgA3DABrEZgsZ1aKENEIwWEyUVjkosttZOVWSwsNHMVESwrEgoNleJ2k1TN6kAcm+dd2zp6+gbBkZAxkJpuJ4ulgAGYAtCaWZvNpLMIrKLrGJnBJJFJpdQZLLmXKIaSWLSSPTmSQHHYVJ4vJwtd4dbq9fqUNBELCgtbwnh8VbRUCxMwyTQE1hGaSsZRmK5mbR4hAE7SJSxGNKWVL7GSqGx2Z6NGkuNr0r5Mmh0BhMTnhblIjaIMyEzRpWradSinapWWVSTFTJGMzSZRqaQGB5U3XNfVuE30RhCJn+QIYYIcZZWtY2uJo5LaNVYzLZD0PHR6VSa5XqdQWUOOcMLdpgrr8TxEVB+AJBMAWxFplEZrTo7OY7H504ZoraKy1ZJkmR+quvWkG+uN5s+VBYZAKTup3niVF9rM5oe4keEhXGVWsI5mcqEud62uaWBEAgEOCwZms9l0LeRbt8lTlAq4rmJeFZmBY8SytIZhaOoyRmJqaTSuo2hKneNZ0k+L5vtQjCmjGP48si-75OUXolKUqgVFk5yWJYHppJodHSBRIqHDIGS2NqZAYBAcCiNSGFtCmv47rEAC0Jx5JJ6Gwm4HjeCJRHpgSRRGFml4IeYqisOoHpZIkZQGLsyjaEGqiyQubifIyUBKdaPZGJIZiaHsvqkjclhJLKdFFJIJaqBKJhktokiWRG7RRmasZ2Qi27Ebu+QVq5hT7BooUsVUekjmoRLFqW5YVk54UPkuTaoPZf6JUG0iaAKIrJGKeaoZIPnQToE67E55TQdIJWYc+r6wPAcWiQlsT7FYTGhaYzk6UkkEntKQoZIFzqTaKYVcUAA */
+ id: 'remoteDocument',
+ context: ({input}) => ({
+ documentId: input.documentId,
+ snapshot: null,
+ mutationEvents: [] as MutationEvent[],
+ error: undefined,
+ attempts: 0,
+ }),
+
+ invoke: {
+ src: 'remoteEvents',
+ id: 'remoteEvents',
+ input: ({context}) => ({documentId: context.documentId}),
+ },
+
+ on: {
+ // If a reconnect event happens, then we should go to a `reconnecting` state that guarantees that we don't attempt retrying fetching the doc until after the EventSource is established and other scenarios
+ reconnect: '.reconnecting',
+ },
+
+ states: {
+ idle: {
+ on: {
+ welcome: 'loading',
+ },
+ },
+
+ loading: {
+ on: {
+ // While the document is loading, buffer up mutation events so we can replay them after the document is fetched
+ mutation: {
+ actions: assign({
+ mutationEvents: ({event, context}) => [
+ ...context.mutationEvents,
+ event,
+ ],
+ error: undefined,
+ }),
+ },
+ reconnect: {
+ actions: assign({mutationEvents: []}),
+ },
+ },
+ invoke: {
+ src: 'getDocument',
+ id: 'getDocument',
+ input: ({context}) => ({documentId: context.documentId}),
+
+ onDone: {
+ target: 'success',
+ actions: assign({
+ // @ts-expect-error @TODO figure out this later and when to use SanityDocument, SanityDocumentBase and all this nonsense
+ snapshot: ({event, context}) => {
+ let document = event.output
+
+ /**
+ * We assume all patches that happen while we're waiting for the document to resolve are already applied.
+ * But if we do see a patch that has the same revision as the document we just fetched, we should apply any patches following it
+ */
+ let seenCurrentRev = false
+ for (const patch of context.mutationEvents) {
+ if (!patch.effects?.apply || !patch.previousRev) continue
+ if (!seenCurrentRev && patch.previousRev === document?._rev) {
+ seenCurrentRev = true
+ }
+ if (seenCurrentRev) {
+ document = applyMendozaPatch(
+ // @ts-expect-error handle later
+ document,
+ patch.effects.apply,
+ patch.resultRev,
+ )
+ }
+ }
+
+ return document
+ },
+ // Since the snapshot handler applies all the patches they are no longer needed, allow GC
+ mutationEvents: [],
+ }),
+ },
+
+ onError: {
+ target: 'failure',
+ actions: assign({
+ error: ({event}) => event.error,
+ // If the document fetch fails then we can assume any mendoza patches we buffered up will no longer be needed
+ mutationEvents: [],
+ }),
+ },
+ },
+ },
+
+ reconnecting: {
+ on: {
+ welcome: 'loading',
+ },
+ },
+
+ failure: {
+ after: {
+ timeout: {
+ actions: assign({attempts: ({context}) => context.attempts + 1}),
+ target: 'loading',
+ },
+ },
+ on: {
+ // We can also manually retry
+ retry: 'loading',
+ },
+ },
+
+ success: {
+ on: {
+ // After the document has loaded, apply the mendoza patches directly
+ mutation: {
+ actions: assign({
+ snapshot: ({event, context}) =>
+ event.effects?.apply
+ ? applyMendozaPatch(
+ // @ts-expect-error handle later
+ context.snapshot,
+ event.effects.apply,
+ event.resultRev,
+ )
+ : context.snapshot,
+ }),
+ },
+ reconnect: {
+ actions: assign({snapshot: null}),
+ },
+ },
+ },
+ },
+
+ initial: 'idle',
+ })
+}
+
+export type RemoteDocumentMachine<
+ DocumentType extends SanityDocumentBase = SanityDocumentBase,
+> = ReturnType>
+
+function applyMendozaPatch(
+ document: DocumentType | undefined,
+ patch: RawPatch,
+ nextRevision: string | undefined,
+) {
+ const next = applyPatch(omitRev(document), patch)
+ if (!next) {
+ return null
+ }
+ return Object.assign(next, {_rev: nextRevision})
+}
+
+function omitRev(
+ document: DocumentType | undefined,
+) {
+ if (!document) {
+ return null
+ }
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ const {_rev, ...doc} = document
+ return doc
+}
diff --git a/examples/visual-editing/shared/state/remote-events-machine.ts b/examples/visual-editing/shared/state/remote-events-machine.ts
new file mode 100644
index 0000000..99c6a25
--- /dev/null
+++ b/examples/visual-editing/shared/state/remote-events-machine.ts
@@ -0,0 +1,146 @@
+import {
+ type MutationEvent,
+ type ReconnectEvent,
+ type SanityClient,
+ type WelcomeEvent,
+} from '@sanity/client'
+import {type SanityDocumentBase} from '@sanity/mutate'
+import {
+ asapScheduler,
+ defer,
+ filter,
+ merge,
+ type ObservedValueOf,
+ observeOn,
+ share,
+ shareReplay,
+ timer,
+} from 'rxjs'
+import {fromEventObservable, fromPromise} from 'xstate'
+
+/**
+ * Creates a single, shared, listener EventSource that strems remote mutations, and notifies when it's online (welcome), offline (reconnect).
+ */
+export function createSharedListener(client: SanityClient) {
+ const allEvents$ = client
+ .listen(
+ '*[!(_id in path("_.**"))]',
+ {},
+ {
+ events: [
+ 'welcome',
+ 'mutation',
+ 'reconnect',
+ // @ts-expect-error - @TODO add this to the client typings
+ 'error',
+ // @ts-expect-error - @TODO add this to the client typings
+ 'channelError',
+ ],
+ includeResult: false,
+ includePreviousRevision: false,
+ visibility: 'transaction',
+ effectFormat: 'mendoza',
+ includeMutations: false,
+ },
+ )
+ .pipe(share({resetOnRefCountZero: true}))
+
+ // Reconnect events emitted in case the connection is lost
+ const reconnect = allEvents$.pipe(
+ filter((event): event is ReconnectEvent => event.type === 'reconnect'),
+ )
+
+ // Welcome events are emitted when the listener is (re)connected
+ const welcome = allEvents$.pipe(
+ filter((event): event is WelcomeEvent => event.type === 'welcome'),
+ )
+
+ // Mutation events coming from the listener
+ const mutations = allEvents$.pipe(
+ filter((event): event is MutationEvent => event.type === 'mutation'),
+ )
+
+ // Replay the latest connection event that was emitted either when the connection was disconnected ('reconnect'), established or re-established ('welcome')
+ const connectionEvent = merge(welcome, reconnect).pipe(
+ shareReplay({bufferSize: 1, refCount: true}),
+ )
+
+ // Emit the welcome event if the latest connection event was the 'welcome' event.
+ // Downstream subscribers will typically map the welcome event to an initial fetch
+ const replayWelcome = connectionEvent.pipe(
+ filter(latestConnectionEvent => latestConnectionEvent.type === 'welcome'),
+ )
+
+ // Combine into a single stream
+ return merge(replayWelcome, mutations, reconnect)
+}
+
+/**
+ * Wraps a shared listener in a new observer that have a delayed unsubscribe, allowing the shared listener to
+ * stay active and connected, useful when it's expected that transitions between observers are frequent and agressive setup and teardown of EventSource is expensive or inefficient.
+ */
+export function sharedListenerWithKeepAlive(
+ sharedListener: ReturnType,
+ keepAlive: number = 1_000,
+) {
+ return defer(() => sharedListener).pipe(
+ share({resetOnRefCountZero: () => timer(keepAlive, asapScheduler)}),
+ )
+}
+
+/** Creates a machine that taps into a shared observable with a delayed unsubscribe */
+export function defineRemoteEvents({
+ listener,
+ keepAlive,
+}: {
+ listener: ReturnType
+ keepAlive: number
+}) {
+ // @TODO verify that it can recover from being offline for a long time and come back online
+ return fromEventObservable(({input}: {input: {documentId: string}}) =>
+ sharedListenerWithKeepAlive(listener, keepAlive).pipe(
+ filter(
+ event =>
+ event.type === 'welcome' ||
+ event.type === 'reconnect' ||
+ (event.type === 'mutation' && event.documentId === input.documentId),
+ ),
+ // This is necessary to avoid sync emitted events from `shareReplay` from happening before the actor is ready to receive them
+ observeOn(asapScheduler),
+ ),
+ )
+}
+export type RemoteEventsMachine = ReturnType
+
+/** Creates a machine that is responsible for fetching documents to be kept in sync with mendoza patches */
+export function defineGetDocument<
+ const DocumentType extends SanityDocumentBase = SanityDocumentBase,
+>({client}: {client: SanityClient}) {
+ return fromPromise(
+ async ({
+ input,
+ signal,
+ }: {
+ input: {documentId: string}
+ signal: AbortSignal
+ }) => {
+ const document = await client
+ .getDocument(input.documentId, {
+ signal,
+ })
+ .catch(e => {
+ if (e instanceof Error && e.name === 'AbortError') return
+ throw e
+ })
+
+ return document
+ },
+ )
+}
+export type GetDocumentMachine<
+ DocumentType extends SanityDocumentBase = SanityDocumentBase,
+> = ReturnType>
+
+export type RemoteSnapshotEvents = ObservedValueOf<
+ ReturnType
+>
diff --git a/examples/visual-editing/shared/state/submit-transactions-machine.ts b/examples/visual-editing/shared/state/submit-transactions-machine.ts
new file mode 100644
index 0000000..8f3e4ab
--- /dev/null
+++ b/examples/visual-editing/shared/state/submit-transactions-machine.ts
@@ -0,0 +1,32 @@
+import {type SanityClient} from '@sanity/client'
+import {SanityEncoder, type Transaction} from '@sanity/mutate'
+import {fromPromise} from 'xstate'
+
+export function defineSubmitTransactions({client}: {client: SanityClient}) {
+ return fromPromise(
+ async ({
+ input,
+ signal,
+ }: {
+ input: {transactions: Transaction[]}
+ signal: AbortSignal
+ }) => {
+ for (const transaction of input.transactions) {
+ if (signal.aborted) return
+ await client
+ .dataRequest('mutate', SanityEncoder.encodeTransaction(transaction), {
+ visibility: 'async',
+ returnDocuments: false,
+ signal,
+ })
+ .catch(e => {
+ if (e instanceof Error && e.name === 'AbortError') return
+ throw e
+ })
+ }
+ },
+ )
+}
+export type SubmitTransactionsMachine = ReturnType<
+ typeof defineSubmitTransactions
+>
diff --git a/examples/visual-editing/shared/state/visual-editor-machine.ts b/examples/visual-editing/shared/state/visual-editor-machine.ts
new file mode 100644
index 0000000..ff26764
--- /dev/null
+++ b/examples/visual-editing/shared/state/visual-editor-machine.ts
@@ -0,0 +1,89 @@
+/**
+ * The logic here is intended to live inside a preview iframe, and listen to events from the parent frame.
+ * It also supports running in a "detached" mode, where it has to setup authenticated EventSource conenctions and perform data fetching itself.
+ */
+
+import {type SanityDocumentBase} from '@sanity/mutate'
+import {type ActorRefFrom, assertEvent, assign, setup, stopChild} from 'xstate'
+
+import {type LocalDocumentMachine} from './local-document-machine'
+
+export function defineVisualEditingMachine<
+ const DocumentType extends SanityDocumentBase = SanityDocumentBase,
+>({
+ localDocumentMachine,
+}: {
+ localDocumentMachine: LocalDocumentMachine
+}) {
+ type LocalDocumentActorRef = ActorRefFrom
+
+ return setup({
+ types: {} as {
+ context: {
+ documents: Record
+ }
+ events:
+ | {type: 'listen'; documentId: string}
+ | {type: 'unlisten'; documentId: string}
+ | {type: 'add document actor'; documentId: string}
+ | {type: 'stop document actor'; documentId: string}
+ | {type: 'remove document actor from context'; documentId: string}
+ },
+ actions: {
+ 'add document actor': assign({
+ documents: ({context, event, spawn}) => {
+ assertEvent(event, 'listen')
+ // Adding the same documentId multiple times is a no-op
+ if (context.documents[event.documentId]) return context.documents
+ return {
+ ...context.documents,
+ [event.documentId]: spawn('localDocumentMachine', {
+ input: {documentId: event.documentId},
+ id: event.documentId,
+ }),
+ }
+ },
+ }),
+ 'stop remote snapshot': stopChild(({context, event}) => {
+ assertEvent(event, 'unlisten')
+ return context.documents[event.documentId]!
+ }),
+ 'remove remote snapshot from context': assign({
+ documents: ({context, event}) => {
+ assertEvent(event, 'unlisten')
+ // Removing a non-existing documentId is a no-op
+ if (!context.documents[event.documentId]) return context.documents
+ return {...context.documents, [event.documentId]: null}
+ },
+ }),
+ },
+ actors: {
+ localDocumentMachine,
+ },
+ }).createMachine({
+ /** @xstate-layout N4IgpgJg5mDOIC5QBsD2BjAhsgIhgrgLZgB2ALgMTICWsZpA2gAwC6ioADqrNWdaiXYgAHogC0ADgBMAOgkA2ACyKArBICcTdfKXSANCACeiFQHYZpgMxWV800yYqp6gIyn5AXw8G0WXAWJyCnwSGjpGViEuHj4BIVEEKUt5OSlFU1smFwkFW0sDY0T1GRUXRXkXKVN7HSYJJkUvHwxsPHQiUjIZDgAnWj4SMApCfDJMemY2JBBo3n5BaYSXeVlbKRUsiXdFSxcXfKNERSqZTbr1zVMJNyaQX1aAzpkIah6yQwp0VEJCXkmo7hzOKLRDLFwyFxaSzXKTOKQudSKCQFRDOczqCSWJjwjbHCQqRFebwgEioCBwIT3fztQJkAExebxcQuFEIZaWGRIrF7dSXKTyFSNYlUtodcjdPp0aiDelAhagBLpVmWAmc46mNwSZTyLQuFS3EWPcUvN6FTiA2LykSoq6c6wbCqVUzparKioyRFVfkq1zqNSWIkeIA */
+ id: 'visual-editor',
+ context: () => ({
+ documents: {},
+ }),
+
+ on: {
+ listen: {
+ actions: ['add document actor'],
+ },
+
+ unlisten: {
+ actions: [
+ 'stop remote snapshot',
+ 'remove remote snapshot from context',
+ ],
+ },
+ },
+
+ initial: 'success',
+
+ states: {
+ success: {},
+ },
+ })
+}
diff --git a/examples/visual-editing/studio/App.tsx b/examples/visual-editing/studio/App.tsx
new file mode 100644
index 0000000..9655f26
--- /dev/null
+++ b/examples/visual-editing/studio/App.tsx
@@ -0,0 +1,616 @@
+import {createClient} from '@sanity/client'
+import {
+ createIfNotExists,
+ del,
+ type Mutation,
+ SanityEncoder,
+} from '@sanity/mutate'
+import {
+ type documentMutatorMachine,
+ type DocumentMutatorMachineParentEvent,
+} from '@sanity/mutate/_unstable_machine'
+import {
+ createDocumentEventListener,
+ createDocumentLoader,
+ createOptimisticStore,
+ createSharedListener,
+ type MutationGroup,
+} from '@sanity/mutate/_unstable_store'
+import {
+ draft,
+ type Infer,
+ isBooleanSchema,
+ isDocumentSchema,
+ isObjectSchema,
+ isObjectUnionSchema,
+ isOptionalSchema,
+ isPrimitiveUnionSchema,
+ isStringSchema,
+ type SanityAny,
+ type SanityBoolean,
+ type SanityObject,
+ type SanityObjectUnion,
+ type SanityOptional,
+ type SanityPrimitiveUnion,
+ type SanityString,
+} from '@sanity/sanitype'
+import {
+ Box,
+ Button,
+ Card,
+ Checkbox,
+ Flex,
+ Grid,
+ Heading,
+ Stack,
+ Tab,
+ TabList,
+ TabPanel,
+ Text,
+} from '@sanity/ui'
+import {useActorRef, useSelector} from '@xstate/react'
+import {
+ Fragment,
+ type ReactNode,
+ useCallback,
+ useDeferredValue,
+ useEffect,
+ useState,
+} from 'react'
+import {concatMap, from, tap} from 'rxjs'
+import styled from 'styled-components'
+import {type ActorRefFrom} from 'xstate'
+
+import {DOCUMENT_IDS} from '../shared/constants'
+import {DocumentView} from '../shared/DocumentView'
+import {JsonView} from '../shared/json-view/JsonView'
+import {datasetMutatorMachine} from './datasetMutatorMachine'
+import {shoeForm} from './forms/shoe'
+import {
+ BooleanInput,
+ DocumentInput,
+ type DocumentInputProps,
+ type InputProps,
+ type MutationEvent,
+ ObjectInput,
+ StringInput,
+ UnionInput,
+} from './lib/form'
+import {FormNode} from './lib/form/FormNode'
+import {ColorInput} from './lib/form/inputs/ColorInput'
+import {PrimitiveUnionInput} from './lib/form/inputs/PrimitiveUnionInput'
+import {FormatMutation} from './lib/mutate-formatter/react'
+import {shoe} from './schema/shoe'
+import {type InspectType} from './types'
+
+function Unresolved(props: InputProps) {
+ return Unresolved input for type {props.schema.typeName}
+}
+
+function OptionalInput>(
+ props: InputProps,
+) {
+ return props.renderInput({
+ ...props,
+ schema: props.schema.type,
+ })
+}
+
+function isColorInputProps(
+ props: InputProps,
+): props is InputProps {
+ return (
+ isStringInputProps(props) &&
+ // @ts-expect-error this is fine
+ props.form?.color
+ )
+}
+function isStringInputProps(
+ props: InputProps,
+): props is InputProps {
+ return isStringSchema(props.schema)
+}
+
+function isOptionalInputProps(
+ props: InputProps,
+): props is InputProps> {
+ return isOptionalSchema(props.schema)
+}
+
+function isObjectInputProps(
+ props: InputProps,
+): props is InputProps {
+ return isObjectSchema(props.schema)
+}
+
+function isDocumentInputProps(
+ props: InputProps | DocumentInputProps,
+): props is DocumentInputProps {
+ return isDocumentSchema(props.schema)
+}
+
+function isObjectUnionInputProps(
+ props: InputProps,
+): props is InputProps {
+ return isObjectUnionSchema(props.schema)
+}
+function isPrimitiveUnionInputProps(
+ props: InputProps,
+): props is InputProps {
+ return isPrimitiveUnionSchema(props.schema)
+}
+function isBooleanInputProps(
+ props: InputProps,
+): props is InputProps {
+ return isBooleanSchema(props.schema)
+}
+
+function renderInput>(
+ props: Props,
+): ReactNode {
+ if (isColorInputProps(props)) {
+ return
+ }
+ if (isStringInputProps(props)) {
+ return
+ }
+ if (isOptionalInputProps(props)) {
+ return
+ }
+ if (isObjectInputProps(props)) {
+ return
+ }
+ if (isDocumentInputProps(props)) {
+ return
+ }
+ if (isObjectUnionInputProps(props)) {
+ return
+ }
+ if (isPrimitiveUnionInputProps(props)) {
+ return
+ }
+ if (isBooleanInputProps(props)) {
+ return
+ }
+ return
+}
+
+const shoeDraft = draft(shoe)
+type ShoeDraft = Infer
+
+const sanityClient = createClient({
+ projectId: import.meta.env.VITE_SANITY_API_PROJECT_ID,
+ dataset: import.meta.env.VITE_SANITY_API_DATASET,
+ apiVersion: '2024-02-28',
+ useCdn: false,
+ token: import.meta.env.VITE_SANITY_API_TOKEN,
+})
+
+const sharedListener = createSharedListener({client: sanityClient})
+
+const loadDocument = createDocumentLoader({client: sanityClient})
+
+const listenDocument = createDocumentEventListener({
+ loadDocument,
+ listenerEvents: sharedListener,
+})
+
+const datastore = createOptimisticStore({
+ listen: listenDocument,
+ submit: transactions => {
+ return from(transactions).pipe(
+ concatMap(transaction =>
+ sanityClient.dataRequest(
+ 'mutate',
+ SanityEncoder.encodeTransaction(transaction),
+ {visibility: 'async', returnDocuments: false},
+ ),
+ ),
+ )
+ },
+})
+
+function App(props: {inspect: InspectType}) {
+ const {inspect} = props
+ const datasetMutatorActorRef = useActorRef(datasetMutatorMachine, {
+ input: {client: sanityClient, sharedListener},
+ inspect,
+ })
+
+ // Open the observed ids on startup
+ useEffect(() => {
+ for (const id of DOCUMENT_IDS) {
+ datasetMutatorActorRef.send({type: 'observe', documentId: id})
+ }
+ return () => {
+ for (const id of DOCUMENT_IDS) {
+ datasetMutatorActorRef.send({type: 'unobserve', documentId: id})
+ }
+ }
+ }, [datasetMutatorActorRef])
+
+ const [documentId, setDocumentId] = useState(DOCUMENT_IDS[0]!)
+ const [documentState, setDocumentState] = useState<{
+ local?: ShoeDraft
+ remote?: ShoeDraft
+ }>({})
+
+ const [staged, setStaged] = useState([])
+ const [autoOptimize, setAutoOptimize] = useState(true)
+
+ const [remoteLogEntries, setRemoteLogEntries] = useState<
+ DocumentMutatorMachineParentEvent[]
+ >([])
+
+ // const cody = useSelector(datasetMutatorActorRef, state => {
+ // const documents = Object.keys(state.context.documents)
+ // const staged: MutationGroup[] = []
+ // for (const id of documents) {
+ // const document = state.context.documents[id]?.getSnapshot()
+ // if (document && document.context.stagedChanges.length > 0) {
+ // staged.push(...document.context.stagedChanges)
+ // }
+ // }
+ // return staged
+ // })
+ // console.log({cody})
+
+ useEffect(() => {
+ const sub = datasetMutatorActorRef.on('*', event => {
+ switch (event.type) {
+ case 'mutation':
+ case 'sync':
+ return setRemoteLogEntries(e => [...e, event].slice(0, 100))
+ }
+ })
+ return () => sub.unsubscribe()
+ }, [datasetMutatorActorRef])
+ useEffect(() => {
+ const staged$ = datastore.meta.stage.pipe(tap(next => setStaged(next)))
+ const sub = staged$.subscribe()
+ return () => sub.unsubscribe()
+ }, [])
+ useEffect(() => {
+ if (!documentId) return
+ const sub = datastore
+ .listenEvents(documentId)
+ .pipe(
+ tap(event => {
+ setDocumentState(current => {
+ return (
+ event.type === 'optimistic'
+ ? {...current, local: event.after}
+ : event.after
+ ) as {remote: ShoeDraft; local: ShoeDraft}
+ })
+ }),
+ )
+ .subscribe()
+ return () => sub.unsubscribe()
+ }, [documentId])
+
+ const handleMutate = useCallback(
+ (mutations: Mutation[]) => {
+ datastore.mutate(mutations)
+ if (autoOptimize) datastore.optimize()
+ },
+ [autoOptimize],
+ )
+ const deferredStaged = useDeferredValue(staged)
+
+ return (
+ <>
+
+
+
+ {DOCUMENT_IDS.map(id => (
+ {
+ setDocumentId(id)
+ }}
+ selected={id === documentId}
+ />
+ ))}
+
+ {DOCUMENT_IDS.map(id => (
+
+ ))}
+
+
+
+
+ >
+ )
+}
+
+function DocumentTabPanelProvider(props: {
+ id: string
+ hidden: boolean
+ datasetMutatorActorRef: ActorRefFrom
+ inspect: InspectType
+}) {
+ const {id, hidden, datasetMutatorActorRef} = props
+ const documentRef = useSelector(
+ datasetMutatorActorRef,
+ state => state.context.documents[id],
+ )
+ const ready = useSelector(documentRef, state => state?.hasTag('ready'))
+
+ return (
+
+ {ready ? (
+
+ ) : (
+ Loading...
+ )}
+
+ )
+}
+
+function DocumentTabPanelContent(props: {
+ id: string
+ hidden: boolean
+ documentRef: ActorRefFrom
+}) {
+ const {id, hidden, documentRef} = props
+ const local = useSelector(documentRef, state => state.context.local)
+ const deferredLocal = useDeferredValue(local)
+ const dirty = useSelector(documentRef, state =>
+ state.matches({connected: {loaded: 'dirty'}}),
+ )
+ const canDelete = useSelector(
+ documentRef,
+ state => state.hasTag('ready') && !!state.context.local,
+ )
+ documentRef.on('error', () => {})
+
+ const handleMutation = (event: MutationEvent) => {
+ documentRef.send({
+ type: 'mutate',
+ mutations: [
+ createIfNotExists({_id: id, _type: shoe.shape._type.value}),
+ ...event.mutations,
+ ],
+ })
+ }
+ const handleDelete = () => {
+ documentRef.send({
+ type: 'mutate',
+ mutations: [del(id)],
+ })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function DocumentTabJsonView(props: {
+ documentRef: ActorRefFrom
+}) {
+ const {documentRef} = props
+
+ const remote = useSelector(documentRef, state => state.context.remote)
+ const deferredRemote = useDeferredValue(remote)
+ const local = useSelector(documentRef, state => state.context.local)
+ const deferredLocal = useDeferredValue(local)
+
+ return (
+
+
+
+
+
+ )
+}
+
+function StagedMutations(props: {
+ staged: MutationGroup[]
+ setAutoOptimize: React.Dispatch>
+ autoOptimize: boolean
+}) {
+ const {staged, autoOptimize, setAutoOptimize} = props
+ return (
+
+
+
+ Staged mutations
+
+
+ {staged.map((e, i) => (
+
+ {e.mutations.map((m, mi) => (
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+ {
+ setAutoOptimize(e.currentTarget.checked)
+ }}
+ />
+ Auto optimize
+
+
+
+
+
+
+ )
+}
+
+function RemoteLogEntries(props: {
+ remoteLogEntries: DocumentMutatorMachineParentEvent[]
+}) {
+ const {remoteLogEntries} = props
+ return (
+
+
+ Remote patches
+
+ {remoteLogEntries.length > 0 && (
+
+ {remoteLogEntries.map((e, i) => (
+
+
+ {e.type}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
+
+const ScrollBack = styled(Stack)`
+ overflow-y: scroll;
+ overscroll-behavior-y: contain;
+ scroll-snap-type: y proximity;
+ .end {
+ scroll-snap-align: end;
+
+ &:only-child {
+ padding-bottom: 0;
+ }
+ }
+`
+
+export default App
diff --git a/examples/visual-editing/studio/datasetMutatorMachine.ts b/examples/visual-editing/studio/datasetMutatorMachine.ts
new file mode 100644
index 0000000..fcd2892
--- /dev/null
+++ b/examples/visual-editing/studio/datasetMutatorMachine.ts
@@ -0,0 +1,117 @@
+/**
+ * The logic here is intended to live inside a preview iframe, and listen to events from the parent frame.
+ * It also supports running in a "detached" mode, where it has to setup authenticated EventSource conenctions and perform data fetching itself.
+ */
+
+import {type SanityClient} from '@sanity/client'
+import {
+ createSharedListener,
+ documentMutatorMachine,
+ type DocumentMutatorMachineInput,
+ type DocumentMutatorMachineParentEvent,
+} from '@sanity/mutate/_unstable_machine'
+import {
+ type ActorRefFrom,
+ assertEvent,
+ assign,
+ emit,
+ setup,
+ stopChild,
+} from 'xstate'
+
+export interface DatasetMutatorMachineInput
+ extends Omit {
+ client: SanityClient
+ /** A shared listener can be provided, if not it'll be created using `client.listen()` */
+ sharedListener?: ReturnType
+}
+
+export const datasetMutatorMachine = setup({
+ types: {} as {
+ context: {
+ client: SanityClient
+ /** A shared listener can be provided, if not it'll be created using `client.listen()` */
+ sharedListener?: ReturnType
+ documents: Record>
+ }
+ events:
+ | {type: 'observe'; documentId: string}
+ | {type: 'unobserve'; documentId: string}
+ | {type: 'add document actor'; documentId: string}
+ | {type: 'stop document actor'; documentId: string}
+ | DocumentMutatorMachineParentEvent
+ input: DatasetMutatorMachineInput
+ emitted: DocumentMutatorMachineParentEvent
+ },
+ actions: {
+ 'emit sync event': emit(({event}) => {
+ assertEvent(event, 'sync')
+ return event
+ }),
+ 'emit mutation event': emit(({event}) => {
+ assertEvent(event, 'mutation')
+ return event
+ }),
+ 'add document actor': assign({
+ documents: ({context, event, spawn}) => {
+ assertEvent(event, 'observe')
+ // Adding the same documentId multiple times is a no-op
+ if (context.documents[event.documentId]) return context.documents
+ return {
+ ...context.documents,
+ [event.documentId]: spawn('documentMutatorMachine', {
+ input: {
+ id: event.documentId,
+ client: context.client,
+ sharedListener:
+ context.sharedListener || createSharedListener(context.client),
+ },
+ id: event.documentId,
+ }),
+ }
+ },
+ }),
+ 'stop remote snapshot': stopChild(({context, event}) => {
+ assertEvent(event, 'unobserve')
+ return context.documents[event.documentId]!
+ }),
+ 'remove remote snapshot from context': assign({
+ documents: ({context, event}) => {
+ assertEvent(event, 'unobserve')
+ // Removing a non-existing documentId is a no-op
+ if (!context.documents[event.documentId]) return context.documents
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ const {[event.documentId]: _, ...documents} = context.documents
+ return documents
+ },
+ }),
+ },
+ actors: {
+ documentMutatorMachine,
+ },
+}).createMachine({
+ /** @xstate-layout N4IgpgJg5mDOIC5QBsD2BjAhsgIhgrgLZgB2ALgMTICWsZpA2gAwC6ioADqrNWdaiXYgAHogC0ADgBMAOgkA2ACyKArBICcTdfKXSANCACeiFQHYZpgMxWV800yYqp6gIyn5AXw8G0WXAWJyCnwSGjpGViEuHj4BIVEEKUt5OSlFU1smFwkFW0sDY0T1GRUXRXkXKVN7HSYJJkUvHwxsPHQiUjIZDgAnWj4SMApCfDJMemY2JBBo3n5BaYSXeVlbKRUsiXdFSxcXfKNERSqZTbr1zVMJNyaQX1aAzpkIah6yQwp0VEJCXkmo7hzOKLRDLFwyFxaSzXKTOKQudSKCQFRDOczqCSWJjwjbHCQqRFebwgEioCBwIT3fztQJkAExebxcQuFEIZaWGRIrF7dSXKTyFSNYlUtodcjdPp0aiDelAhagBLpVmWAmc46mNwSZTyLQuFS3EWPcUvN6FTiA2LykSoq6c6wbCqVUzparKioyRFVfkq1zqNSWIkeIA */
+ id: 'dataset-mutator',
+ context: ({input}) => ({
+ documents: {},
+ client: input.client,
+ sharedListener: input.sharedListener,
+ }),
+
+ on: {
+ sync: {actions: ['emit sync event']},
+ mutation: {actions: ['emit mutation event']},
+ observe: {
+ actions: ['add document actor'],
+ },
+ unobserve: {
+ actions: ['stop remote snapshot', 'remove remote snapshot from context'],
+ },
+ },
+
+ initial: 'pristine',
+
+ states: {
+ pristine: {},
+ },
+})
diff --git a/examples/visual-editing/studio/forms/shoe.ts b/examples/visual-editing/studio/forms/shoe.ts
new file mode 100644
index 0000000..61c104c
--- /dev/null
+++ b/examples/visual-editing/studio/forms/shoe.ts
@@ -0,0 +1,149 @@
+import {defineForm} from '@sanity/sanitype'
+
+import {shoe} from '../schema/shoe'
+
+export const shoeForm = defineForm(shoe, {
+ fields: {
+ name: {
+ title: 'Name',
+ },
+ model: {
+ title: 'Model',
+ types: {
+ airmax: {
+ title: 'Air Max',
+ fields: {
+ color: {
+ title: 'Color',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ gel: {
+ title: 'Gel',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ },
+ },
+ dunklow: {
+ title: 'Dunk Low',
+ fields: {
+ coatFront: {
+ title: 'Coat Front',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ coatMiddle: {
+ title: 'Coat Middle',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ coatBack: {
+ title: 'Coat Back',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ inner: {
+ title: 'Inner',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ laces: {
+ title: 'Laces',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ neck: {
+ title: 'Neck',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ nikeLogo: {
+ title: 'Nike Logo',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ nikeText: {
+ title: 'Nike Text',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ patch: {
+ title: 'Patch',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ soleTop: {
+ title: 'Sole Top',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ soleBottom: {
+ title: 'Sole Bottom',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ towel: {
+ title: 'Towel',
+ // @ts-expect-error it's fine
+ color: true,
+ },
+ },
+ },
+ ultraboost: {
+ title: 'Ultraboost',
+ fields: {
+ band: {
+ title: 'Band',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 2,
+ },
+ caps: {
+ title: 'Caps',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 1,
+ },
+ inner: {
+ title: 'Inner',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 4,
+ },
+ laces: {
+ title: 'Laces',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 4,
+ },
+ mesh: {
+ title: 'Mesh',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 0,
+ },
+ patch: {
+ title: 'Patch',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 0,
+ },
+ sole: {
+ title: 'Sole',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 1,
+ },
+ stripes: {
+ title: 'Stripes',
+ // @ts-expect-error it's fine
+ color: true,
+ preset: 1,
+ },
+ },
+ },
+ },
+ },
+ },
+})
diff --git a/examples/visual-editing/studio/index.tsx b/examples/visual-editing/studio/index.tsx
new file mode 100644
index 0000000..17c12e2
--- /dev/null
+++ b/examples/visual-editing/studio/index.tsx
@@ -0,0 +1,116 @@
+import {Card, Grid} from '@sanity/ui'
+import {createBrowserInspector} from '@statelyai/inspect'
+import {useEffect, useRef, useState} from 'react'
+import {styled} from 'styled-components'
+
+// import * as Comlink from 'comlink'
+import {globalSymbol} from '../shared/inspector'
+import App from './App'
+import {type InspectType} from './types'
+
+export default function Studio(props: {debug: boolean}) {
+ const {debug} = props
+ const [createdInspector, setCreatedInspector] = useState(false)
+ const parentInspectorIframeRef = useRef(null)
+ const childInspectorIframeRef = useRef(null)
+ // const [sendToPreview, setSendToPreview] = useState<
+ // null | ((event: unknown) => void)
+ // >(null)
+
+ useEffect(() => {
+ if (!parentInspectorIframeRef.current) return
+
+ const iframe = parentInspectorIframeRef.current
+ const raf = requestAnimationFrame(() => {
+ const inspector = createBrowserInspector({iframe})
+
+ setInspect(inspector.inspect)
+ })
+ return () => cancelAnimationFrame(raf)
+ }, [])
+
+ useEffect(() => {
+ if (!childInspectorIframeRef.current) return
+
+ const iframe = childInspectorIframeRef.current
+ const raf = requestAnimationFrame(() => {
+ const inspector = createBrowserInspector({iframe})
+
+ // @ts-expect-error this is fine
+ window[globalSymbol] = inspector
+ })
+ return () => cancelAnimationFrame(raf)
+ }, [])
+
+ const [inspect, setInspect] = useState(undefined)
+
+ return (
+ <>
+
+
+
+
+ {(createdInspector || !debug) && }
+ {debug && (
+
+
+
+
+
+
+
+ )}
+
+
+ >
+ )
+}
+
+const Iframe = styled.iframe`
+ display: block;
+ height: 100%;
+ width: 100%;
+ margin: 0;
+ border: 0;
+ background: transparent;
+ overflow: auto;
+ border-radius: inherit;
+`
+
+const Container = styled(Grid)<{$debug: boolean}>`
+ box-sizing: border-box;
+ height: 100vh;
+ max-height: 100dvh;
+ overflow: clip;
+ overscroll-behavior: none;
+ grid-template-areas: ${props =>
+ props.$debug
+ ? `
+ 'preview editor inspect'
+ 'staged staged inspect'
+ 'remote remote inspect'`
+ : `
+ 'preview editor staged'
+ 'preview editor remote'`};
+ /* grid-template-columns: 2fr 2fr 3fr; */
+ grid-template-columns: minmax(auto, 400px) minmax(auto, 440px) 3fr;
+ grid-auto-rows: ${props =>
+ props.$debug ? '1fr min-content min-content' : '1fr min-content'};
+`
diff --git a/examples/visual-editing/studio/lib/form/FormNode.tsx b/examples/visual-editing/studio/lib/form/FormNode.tsx
new file mode 100644
index 0000000..3b18743
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/FormNode.tsx
@@ -0,0 +1,159 @@
+import {
+ at,
+ patch,
+ type Path,
+ type PathElement,
+ setIfMissing,
+} from '@sanity/mutate'
+import {
+ getInstanceName,
+ type Infer,
+ isDocumentSchema,
+ isObjectSchema,
+ isObjectUnionSchema,
+ isOptionalSchema,
+ type ObjectFormDef,
+ type ObjectUnionFormDef,
+ type SanityDocument,
+ type SanityFormDef,
+ type SanityType,
+ type SanityTypedObject,
+} from '@sanity/sanitype'
+import {Stack, Text} from '@sanity/ui'
+import {useCallback, useMemo} from 'react'
+
+import {DocumentInput} from './inputs/objects/DocumentInput'
+import {type DocumentInputProps, type PatchEvent} from './types'
+
+type FormNodeProps =
+ DocumentInputProps & {
+ path: Path
+ }
+
+type Node = {
+ type: 'field'
+ pathElement: PathElement
+ schema: SanityType
+ form?: SanityFormDef
+ value: any
+}
+
+function resolveNode(
+ path: PathElement[] | readonly PathElement[],
+ schema: Schema,
+ value: Infer | undefined,
+ form?: SanityFormDef,
+): Node[] {
+ if (path.length === 0) {
+ return []
+ }
+
+ if (isObjectSchema(schema) || isDocumentSchema(schema)) {
+ const [fieldName, ...rest] = path
+
+ if (typeof fieldName !== 'string') {
+ throw new Error('Expected field name')
+ }
+ const fieldType = schema.shape[fieldName]
+ if (!fieldType) {
+ throw new Error(`Form definition for field "${fieldName}" not found`)
+ }
+ const fieldValue = value?.[fieldName]
+ const fieldForm = (form as ObjectFormDef)?.fields[fieldName]
+ return [
+ {
+ type: 'field',
+ pathElement: fieldName,
+ schema: fieldType,
+ value: fieldValue,
+ form: fieldForm,
+ },
+ ...resolveNode(rest, fieldType, fieldValue, fieldForm),
+ ]
+ }
+
+ if (isObjectUnionSchema(schema)) {
+ const type = value?._type
+ const valueType = schema.union.find(ut => getInstanceName(ut) === type)!
+ const typeForm = (
+ (form as ObjectUnionFormDef).types as any
+ )[type]
+ return resolveNode(path, valueType, value, typeForm)
+ }
+
+ if (isOptionalSchema(schema)) {
+ return resolveNode(path, schema.type, value, form)
+ }
+
+ return []
+}
+
+function FormNode_(
+ props: FormNodeProps,
+) {
+ const {renderInput, path, schema, value, onMutation, form} = props
+
+ if (path.length === 0) {
+ throw new Error('The FormNode component requires a non-empty path')
+ }
+
+ const nodes = useMemo(() => {
+ return resolveNode(path, schema, value, form)
+ }, [form, path, schema, value])
+
+ const last = nodes.at(-1)
+
+ if (!last) {
+ throw new Error('Expected at least one node')
+ }
+
+ if (!last.form) {
+ throw new Error(`No form definition for field "${path.at(-1)}"`)
+ }
+
+ const handlePatch = useCallback(
+ (patchEvent: PatchEvent) => {
+ const patches = nodes.reduceRight((prev, node) => {
+ const instanceName = getInstanceName(schema)
+ return [
+ at([], setIfMissing(instanceName ? {_type: instanceName} : {})),
+ ...prev.map(nodePatch =>
+ at([node.pathElement, ...nodePatch.path], nodePatch.op),
+ ),
+ ]
+ }, patchEvent.patches)
+ onMutation({
+ mutations: patches.map(nodePatch => patch(value._id, nodePatch)),
+ })
+ },
+ [nodes, onMutation, schema, value._id],
+ )
+ const input = renderInput({
+ schema: last.schema as Schema,
+ onPatch: handlePatch,
+ value: last?.value,
+ path,
+ renderInput,
+ form: last.form,
+ })
+
+ return (
+
+
+ {input}
+
+ )
+}
+export function FormNode(
+ props: FormNodeProps,
+) {
+ return props.path.length === 0 ? (
+
+ ) : (
+
+ )
+}
diff --git a/examples/visual-editing/studio/lib/form/index.ts b/examples/visual-editing/studio/lib/form/index.ts
new file mode 100644
index 0000000..1169f07
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/index.ts
@@ -0,0 +1,6 @@
+export * from './inputs/BooleanInput'
+export * from './inputs/objects/DocumentInput'
+export * from './inputs/objects/ObjectInput'
+export * from './inputs/StringInput'
+export * from './inputs/UnionInput'
+export * from './types'
diff --git a/examples/visual-editing/studio/lib/form/inputs/BooleanInput.tsx b/examples/visual-editing/studio/lib/form/inputs/BooleanInput.tsx
new file mode 100644
index 0000000..66f6027
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/BooleanInput.tsx
@@ -0,0 +1,17 @@
+import {at, set} from '@sanity/mutate'
+import {type SanityBoolean} from '@sanity/sanitype'
+import {Checkbox} from '@sanity/ui'
+import {type FormEventHandler, useCallback} from 'react'
+
+import {type InputProps} from '../types'
+
+export function BooleanInput(props: InputProps) {
+ const {onPatch} = props
+ const handleChange: FormEventHandler = useCallback(
+ event => {
+ onPatch({patches: [at([], set(event.currentTarget.checked))]})
+ },
+ [onPatch],
+ )
+ return
+}
diff --git a/examples/visual-editing/studio/lib/form/inputs/ColorInput.tsx b/examples/visual-editing/studio/lib/form/inputs/ColorInput.tsx
new file mode 100644
index 0000000..7aa03b0
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/ColorInput.tsx
@@ -0,0 +1,106 @@
+import {at, set, unset} from '@sanity/mutate'
+import {type SanityString} from '@sanity/sanitype'
+import {Inline, Text} from '@sanity/ui'
+import defaultColors from 'nice-color-palettes'
+import {transparentize} from 'polished'
+import {
+ type FormEventHandler,
+ useCallback,
+ useDeferredValue,
+ useId,
+} from 'react'
+import {styled} from 'styled-components'
+
+import {type InputProps} from '../types'
+
+export function ColorInput(props: InputProps) {
+ const {value, onPatch} = props
+ const deferredValue = useDeferredValue(value)
+ const handleChange: FormEventHandler =
+ useCallback(
+ event => {
+ onPatch({
+ patches: [
+ at(
+ [],
+ event.currentTarget.value
+ ? set(event.currentTarget.value)
+ : unset(),
+ ),
+ ],
+ })
+ },
+ [onPatch],
+ )
+ const inputId = useId()
+ const presetId = useId()
+
+ return (
+
+
+
+ {deferredValue}
+
+ {
+ // @ts-expect-error this fine, preset exists
+ typeof props.form?.preset === 'number' && (
+
+ )
+ }
+
+ )
+}
+
+const StyledColorInput = styled.input.attrs({type: 'color'})`
+ cursor: pointer;
+ box-sizing: border-box;
+ background: var(--card-border-color);
+ border: 0 solid transparent;
+ border-radius: 2px;
+ padding: 0;
+ appearance: none;
+ margin: 0;
+ height: 1.6rem;
+ width: 8ch;
+
+ &:hover {
+ box-shadow: 0 0 0 2px ${({theme}) => theme.sanity.color.card.hovered.border};
+ }
+
+ &::-webkit-color-swatch-wrapper {
+ padding: 0;
+ }
+
+ &::-webkit-color-swatch {
+ padding: 0;
+ border: 0 solid transparent;
+ border-radius: 2px;
+ box-shadow: inset 0 0 0 1px
+ ${({theme}) => transparentize(0.8, theme.sanity.color.card.enabled.fg)};
+ }
+
+ &::-moz-color-swatch {
+ padding: 0;
+ border: 0 solid transparent;
+ border-radius: 2px;
+ box-shadow: inset 0 0 0 1px
+ ${({theme}) => transparentize(0.8, theme.sanity.color.card.enabled.fg)};
+ }
+`
diff --git a/examples/visual-editing/studio/lib/form/inputs/PrimitiveUnionInput.tsx b/examples/visual-editing/studio/lib/form/inputs/PrimitiveUnionInput.tsx
new file mode 100644
index 0000000..a40caef
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/PrimitiveUnionInput.tsx
@@ -0,0 +1,43 @@
+import {at, set, unset} from '@sanity/mutate'
+import {isLiteralSchema, type SanityPrimitiveUnion} from '@sanity/sanitype'
+import {Box, Flex, Select, Stack} from '@sanity/ui'
+import {useCallback} from 'react'
+
+import {type InputProps} from '../types'
+
+export function PrimitiveUnionInput(props: InputProps) {
+ const {value, schema, onPatch, form} = props
+
+ const handleReplaceType = useCallback(
+ (nextValue: string) =>
+ onPatch({
+ patches: [at([], nextValue === '' ? unset() : set(nextValue))],
+ }),
+ [onPatch],
+ )
+
+ const literalTypes = schema.union.filter(isLiteralSchema)
+ // todo: support non-literal primitives
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/examples/visual-editing/studio/lib/form/inputs/StringInput.tsx b/examples/visual-editing/studio/lib/form/inputs/StringInput.tsx
new file mode 100644
index 0000000..4d0e1f0
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/StringInput.tsx
@@ -0,0 +1,32 @@
+import {at, set, unset} from '@sanity/mutate'
+import {type SanityString} from '@sanity/sanitype'
+import {TextArea, TextInput} from '@sanity/ui'
+import {type FormEventHandler, useCallback} from 'react'
+
+import {type InputProps} from '../types'
+
+export function StringInput(props: InputProps) {
+ const {value, onPatch} = props
+ const handleChange: FormEventHandler =
+ useCallback(
+ event => {
+ onPatch({
+ patches: [
+ at(
+ [],
+ event.currentTarget.value
+ ? set(event.currentTarget.value)
+ : unset(),
+ ),
+ ],
+ })
+ },
+ [onPatch],
+ )
+
+ return props.form?.multiline ? (
+
+ ) : (
+
+ )
+}
diff --git a/examples/visual-editing/studio/lib/form/inputs/UnionInput.tsx b/examples/visual-editing/studio/lib/form/inputs/UnionInput.tsx
new file mode 100644
index 0000000..7e56ff5
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/UnionInput.tsx
@@ -0,0 +1,200 @@
+import {EllipsisVerticalIcon, TransferIcon, TrashIcon} from '@sanity/icons'
+import {assign, at, set, unset} from '@sanity/mutate'
+import {
+ getInstanceName,
+ isObjectSchema,
+ type ObjectUnionFormDef,
+ pickDeep,
+ type SanityObjectUnion,
+} from '@sanity/sanitype'
+import {
+ Box,
+ Button,
+ Card,
+ Flex,
+ Menu,
+ MenuButton,
+ type MenuButtonProps,
+ MenuDivider,
+ MenuGroup,
+ MenuItem,
+ Select,
+ Stack,
+ Text,
+} from '@sanity/ui'
+import {startCase} from 'lodash'
+import {useCallback} from 'react'
+
+import {type InputProps, type PatchEvent} from '../types'
+import {ObjectInput} from './objects/ObjectInput'
+
+const NESTED_POPOVER_PROPS: MenuButtonProps['popover'] = {
+ placement: 'right-start',
+ portal: true,
+ preventOverflow: true,
+}
+
+export function UnionInput(props: InputProps) {
+ const {value, schema, onPatch, path, form, renderInput} = props
+ const valueTypeName = value?._type
+
+ const currentSchema = valueTypeName
+ ? schema.union.find(ut => getInstanceName(ut) === valueTypeName)
+ : undefined
+
+ const handlePatch = useCallback(
+ (patchEvent: PatchEvent) => {
+ if (!currentSchema) {
+ // note: this should never happen
+ throw new Error(`Cannot apply patch. No current to apply patch to`)
+ }
+ onPatch(patchEvent)
+ },
+ [onPatch, currentSchema],
+ )
+
+ const handleTurnInto = useCallback(
+ (nextTypeName: string) => {
+ const nextSchema = schema.union.find(
+ ut => getInstanceName(ut) === nextTypeName,
+ )
+ if (!nextSchema) {
+ throw new Error(`No valid union type named ${nextTypeName}.`)
+ }
+ onPatch({
+ patches: [
+ at([], set({_type: nextTypeName})),
+ at([], assign(pickDeep(nextSchema, value))),
+ ],
+ })
+ },
+ [onPatch, value, schema],
+ )
+
+ const handleSelectType = useCallback(
+ (nextTypeName: string) => {
+ const nextSchema = schema.union.find(
+ ut => getInstanceName(ut) === nextTypeName,
+ )
+ if (!nextSchema) {
+ throw new Error(`No valid union type named ${nextTypeName}.`)
+ }
+ onPatch({
+ patches: [at([], set({_type: nextTypeName}))],
+ })
+ },
+ [schema.union, onPatch],
+ )
+
+ const handleClear = useCallback(
+ () =>
+ onPatch({
+ patches: [at([], unset())],
+ }),
+ [onPatch],
+ )
+
+ if (!currentSchema) {
+ return (
+
+ )
+ }
+
+ if (!isObjectSchema(currentSchema)) {
+ return Type {valueTypeName} not valid for union
+ }
+
+ const unionTypes = schema.union
+ const otherTypes = unionTypes.filter(u => u !== currentSchema)
+ return (
+
+
+
+
+
+
+ {startCase(valueTypeName)}
+
+
+
+ }
+ id="menu-button-example"
+ menu={
+
+ }
+ popover={{portal: true, tone: 'default'}}
+ />
+
+
+
+
+
+
+
+ )
+}
+
+function intersection(a: T1[], b: T1[]): T1[] {
+ return a.filter(x => b.includes(x))
+}
diff --git a/examples/visual-editing/studio/lib/form/inputs/objects/DocumentInput.tsx b/examples/visual-editing/studio/lib/form/inputs/objects/DocumentInput.tsx
new file mode 100644
index 0000000..e9771da
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/objects/DocumentInput.tsx
@@ -0,0 +1,41 @@
+import {at, patch} from '@sanity/mutate'
+import {type SanityDocument} from '@sanity/sanitype'
+import {Stack} from '@sanity/ui'
+import {useCallback} from 'react'
+
+import {type DocumentInputProps, type PatchEvent} from '../../types'
+import {Field} from './Field'
+
+export function DocumentInput(
+ props: DocumentInputProps,
+) {
+ const {value, onMutation, renderInput} = props
+ const handleFieldPatch = useCallback(
+ (fieldName: string, patchEvent: PatchEvent) => {
+ onMutation({
+ mutations: patchEvent.patches.map(nodePatch =>
+ patch(value._id, at([fieldName, ...nodePatch.path], nodePatch.op)),
+ ),
+ })
+ },
+ [onMutation, value._id],
+ )
+ return (
+
+ {Object.entries(props.form.fields).map(([fieldName, fieldOptions]) => {
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/examples/visual-editing/studio/lib/form/inputs/objects/Field.tsx b/examples/visual-editing/studio/lib/form/inputs/objects/Field.tsx
new file mode 100644
index 0000000..6ec8b7c
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/objects/Field.tsx
@@ -0,0 +1,56 @@
+import {type Path} from '@sanity/mutate'
+import {
+ type CommonFormOptions,
+ type Infer,
+ type SanityAny,
+ type SanityFormDef,
+ type SanityType,
+} from '@sanity/sanitype'
+import {Stack, Text} from '@sanity/ui'
+import {memo, type ReactNode, useCallback, useMemo} from 'react'
+
+import {type InputProps, type PatchEvent} from '../../types'
+
+export type FieldProps = {
+ schema: Schema
+ name: string
+ value?: Infer
+ options: CommonFormOptions
+ path: Path
+ onPatch: (fieldName: string, patchEvent: PatchEvent) => void
+ // onMutate: (mutationEvent: MutationEvent) => void // todo: consider support for patching other documents too
+ renderInput: >(props: T) => ReactNode
+}
+
+export const Field = memo(function Field(
+ props: FieldProps,
+) {
+ const {schema, name, value, renderInput, onPatch, path, options} = props
+
+ const handlePatch = useCallback(
+ (patchEvent: PatchEvent) => onPatch(name, patchEvent),
+ [name, onPatch],
+ )
+ const input = useMemo(
+ () =>
+ renderInput({
+ schema,
+ renderInput,
+ onPatch: handlePatch,
+ value,
+ path: [...path, name],
+ form: options as SanityFormDef,
+ }),
+ [handlePatch, name, options, path, renderInput, schema, value],
+ )
+ return (
+
+
+ {input}
+
+ )
+})
diff --git a/examples/visual-editing/studio/lib/form/inputs/objects/ObjectInput.tsx b/examples/visual-editing/studio/lib/form/inputs/objects/ObjectInput.tsx
new file mode 100644
index 0000000..aed3ee5
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/inputs/objects/ObjectInput.tsx
@@ -0,0 +1,46 @@
+import {at, setIfMissing} from '@sanity/mutate'
+import {getInstanceName, type SanityObject} from '@sanity/sanitype'
+import {Card, Stack} from '@sanity/ui'
+import {useCallback} from 'react'
+
+import {type InputProps, type PatchEvent} from '../../types'
+import {Field} from './Field'
+
+export function ObjectInput(props: InputProps) {
+ const {schema, onPatch, value, renderInput, path} = props
+ const handleFieldPatch = useCallback(
+ (fieldName: string, patchEvent: PatchEvent) => {
+ const instanceName = getInstanceName(schema)
+
+ onPatch({
+ patches: [
+ at([], setIfMissing(instanceName ? {_type: instanceName} : {})),
+ ...patchEvent.patches.map(patch =>
+ at([fieldName, ...patch.path], patch.op),
+ ),
+ ],
+ })
+ },
+ [schema, onPatch],
+ )
+ return (
+
+
+ {Object.entries(props.form.fields).map(([fieldName, fieldOptions]) => {
+ return (
+
+ )
+ })}
+
+
+ )
+}
diff --git a/examples/visual-editing/studio/lib/form/json.ts b/examples/visual-editing/studio/lib/form/json.ts
new file mode 100644
index 0000000..40578de
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/json.ts
@@ -0,0 +1,6 @@
+export type JsonObject = {[Key in string]: JsonValue} & {
+ [Key in string]?: JsonValue | undefined
+}
+export type JsonArray = JsonValue[] | readonly JsonValue[]
+export type JsonPrimitive = string | number | boolean | null
+export type JsonValue = JsonPrimitive | JsonObject | JsonArray
diff --git a/examples/visual-editing/studio/lib/form/types.ts b/examples/visual-editing/studio/lib/form/types.ts
new file mode 100644
index 0000000..0a36b6b
--- /dev/null
+++ b/examples/visual-editing/studio/lib/form/types.ts
@@ -0,0 +1,37 @@
+import {type Mutation, type NodePatch, type Path} from '@sanity/mutate'
+import {
+ type Infer,
+ type SanityAny,
+ type SanityDocument,
+ type SanityFormDef,
+} from '@sanity/sanitype'
+import {type ReactNode} from 'react'
+
+export type InputProps = {
+ schema: Schema
+ form: SanityFormDef
+ value?: Infer
+ onPatch: (patchEvent: PatchEvent) => void
+ path: Path
+ // onMutate: (mutationEvent: MutationEvent) => void // todo: consider support for patching other documents too
+ renderInput: >(inputProps: T) => ReactNode
+}
+
+export type DocumentInputProps =
+ {
+ schema: Schema
+ form: SanityFormDef
+ value: Infer
+
+ onMutation: (mutationEvent: MutationEvent) => void
+ // onMutate: (mutationEvent: MutationEvent) => void // todo: consider support for patching other documents too
+ renderInput: >(inputProps: T) => ReactNode
+ }
+
+export type MutationEvent = {
+ mutations: Mutation[]
+}
+
+export type PatchEvent = {
+ patches: NodePatch[]
+}
diff --git a/examples/visual-editing/studio/lib/mutate-formatter/react/components/FormatMutation.tsx b/examples/visual-editing/studio/lib/mutate-formatter/react/components/FormatMutation.tsx
new file mode 100644
index 0000000..8a61cfe
--- /dev/null
+++ b/examples/visual-editing/studio/lib/mutate-formatter/react/components/FormatMutation.tsx
@@ -0,0 +1,69 @@
+import {type Mutation} from '@sanity/mutate'
+import {Box, Flex, Inline, Stack, Text} from '@sanity/ui'
+
+import {JsonView} from '../../../../../shared/json-view/JsonView'
+import {FormatNodePatch} from './FormatPatchMutation'
+
+interface FormatMutationProps {
+ mutation: Mutation
+}
+
+export function FormatMutation(props: FormatMutationProps) {
+ const {mutation} = props
+ if (
+ mutation.type === 'create' ||
+ mutation.type === 'createIfNotExists' ||
+ mutation.type === 'createOrReplace'
+ ) {
+ return (
+
+
+ {mutation.type}
+
+ ()
+
+ )
+ }
+ if (mutation.type === 'delete') {
+ return (
+
+
+ {mutation.type}
+
+ ()
+
+ )
+ }
+ if (mutation.type === 'patch') {
+ const ifRevision = mutation.options?.ifRevision
+ return (
+ <>
+
+
+
+
+ {mutation.type}
+
+ ()
+
+ {ifRevision ? (
+
+
+ if revision=={ifRevision}
+
+
+ ) : null}
+
+ {mutation.patches.map((nodePatch, i) => (
+
+
+
+ ))}
+
+ >
+ )
+ }
+
+ //@ts-expect-error - all cases are covered
+ return Invalid mutation type: ${mutation.type}
+}
diff --git a/examples/visual-editing/studio/lib/mutate-formatter/react/components/FormatPatchMutation.tsx b/examples/visual-editing/studio/lib/mutate-formatter/react/components/FormatPatchMutation.tsx
new file mode 100644
index 0000000..300ca94
--- /dev/null
+++ b/examples/visual-editing/studio/lib/mutate-formatter/react/components/FormatPatchMutation.tsx
@@ -0,0 +1,130 @@
+import {
+ type Index,
+ type KeyedPathElement,
+ type NodePatch,
+ type Operation,
+} from '@sanity/mutate'
+import {stringify as stringifyPath} from '@sanity/mutate/path'
+import {Flex, Inline, Text} from '@sanity/ui'
+
+import {JsonView} from '../../../../../shared/json-view/JsonView'
+
+function formatReferenceItem(ref: Index | KeyedPathElement) {
+ return `[_key==${typeof ref === 'number' ? ref : ref._key}]`
+}
+
+export function FormatNodePatch(props: {patch: NodePatch}) {
+ const {patch} = props
+
+ const path = stringifyPath(patch.path)
+
+ return (
+
+
+
+ {path}:
+
+
+
+ )
+}
+function FormatOp(props: {op: Operation}) {
+ const {op} = props
+ if (op.type === 'unset') {
+ return (
+
+
+ {op.type}
+
+ ()
+
+ )
+ }
+ if (op.type === 'diffMatchPatch') {
+ return (
+
+
+ {op.type}
+
+ ({op.value})
+
+ )
+ }
+ if (op.type === 'inc' || op.type === 'dec') {
+ return (
+
+
+ {op.type}
+
+ (${op.amount})
+
+ )
+ }
+ if (op.type === 'set' || op.type === 'setIfMissing') {
+ return (
+
+
+ {op.type}
+
+
+ ()
+
+
+ )
+ }
+ if (op.type === 'assign') {
+ return (
+
+
+ {op.type}
+
+
+ ()
+
+
+ )
+ }
+ if (op.type === 'unassign') {
+ return (
+
+
+ {op.type}
+
+ ({op.keys.join(', ')})
+
+ )
+ }
+ if (op.type === 'insert' || op.type === 'upsert') {
+ return (
+ <>
+
+ {op.type} {op.position}({formatReferenceItem(op.referenceItem)})
+
+
+
+
+ >
+ )
+ }
+ if (op.type === 'replace') {
+ return (
+ <>
+
+ {op.type} ({formatReferenceItem(op.referenceItem)})
+
+
+
+
+ >
+ )
+ }
+ if (op.type === 'truncate') {
+ return (
+
+ {op.type} ({op.startIndex}, {op.endIndex}))
+
+ )
+ }
+ // @ts-expect-error all cases are covered
+ throw new Error(`Invalid operation type: ${op.type}`)
+}
diff --git a/examples/visual-editing/studio/lib/mutate-formatter/react/index.ts b/examples/visual-editing/studio/lib/mutate-formatter/react/index.ts
new file mode 100644
index 0000000..469ec90
--- /dev/null
+++ b/examples/visual-editing/studio/lib/mutate-formatter/react/index.ts
@@ -0,0 +1 @@
+export {FormatMutation} from './components/FormatMutation'
diff --git a/examples/visual-editing/studio/schema/shoe.ts b/examples/visual-editing/studio/schema/shoe.ts
new file mode 100644
index 0000000..bd7f2a5
--- /dev/null
+++ b/examples/visual-editing/studio/schema/shoe.ts
@@ -0,0 +1,48 @@
+import {
+ document,
+ literal,
+ object,
+ optional,
+ string,
+ union,
+} from '@sanity/sanitype'
+
+export const airmax = object({
+ _type: literal('airmax'),
+ color: optional(string()),
+ gel: optional(string()),
+})
+
+export const dunklow = object({
+ _type: literal('dunklow'),
+ coatBack: optional(string()),
+ coatFront: optional(string()),
+ coatMiddle: optional(string()),
+ inner: optional(string()),
+ laces: optional(string()),
+ neck: optional(string()),
+ nikeLogo: optional(string()),
+ nikeText: optional(string()),
+ patch: optional(string()),
+ soleBottom: optional(string()),
+ soleTop: optional(string()),
+ towel: optional(string()),
+})
+
+export const ultraboost = object({
+ _type: literal('ultraboost'),
+ band: optional(string()),
+ caps: optional(string()),
+ inner: optional(string()),
+ laces: optional(string()),
+ mesh: optional(string()),
+ patch: optional(string()),
+ sole: optional(string()),
+ stripes: optional(string()),
+})
+
+export const shoe = document({
+ _type: literal('shoe'),
+ name: string(),
+ model: optional(union([airmax, dunklow, ultraboost])),
+})
diff --git a/examples/visual-editing/studio/types.ts b/examples/visual-editing/studio/types.ts
new file mode 100644
index 0000000..f756418
--- /dev/null
+++ b/examples/visual-editing/studio/types.ts
@@ -0,0 +1,5 @@
+import {type createBrowserInspector} from '@statelyai/inspect'
+
+export type InspectType =
+ | ReturnType['inspect']
+ | undefined
diff --git a/examples/visual-editing/tsconfig.json b/examples/visual-editing/tsconfig.json
new file mode 100644
index 0000000..215b596
--- /dev/null
+++ b/examples/visual-editing/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "include": ["./**/*.ts", "./**/*.tsx"],
+ "extends": "../../tsconfig.settings.json",
+ "compilerOptions": {
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "esModuleInterop": true,
+ "target": "esnext",
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "paths": {
+ "@sanity/mutate": ["../../src"],
+ "@sanity/mutate/_unstable_machine": ["../../src/_unstable_machine"],
+ "@sanity/mutate/_unstable_store": ["../../src/_unstable_store"],
+ "@sanity/mutate/path": ["../../src/_path"]
+ }
+ }
+}
diff --git a/examples/visual-editing/vite-env.d.ts b/examples/visual-editing/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/examples/visual-editing/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/visual-editing/vite.config.ts b/examples/visual-editing/vite.config.ts
new file mode 100644
index 0000000..cef0814
--- /dev/null
+++ b/examples/visual-editing/vite.config.ts
@@ -0,0 +1,16 @@
+import react from '@vitejs/plugin-react'
+import {defineConfig} from 'vite'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ server: {
+ port: 3333,
+ },
+ plugins: [
+ react({
+ babel: {
+ plugins: [['babel-plugin-react-compiler', {target: '18'}]],
+ },
+ }),
+ ],
+})
diff --git a/examples/web/App.tsx b/examples/web/App.tsx
index f4d2b1a..51c2a0e 100644
--- a/examples/web/App.tsx
+++ b/examples/web/App.tsx
@@ -160,9 +160,7 @@ const sanityClient = createClient({
token: import.meta.env.VITE_SANITY_API_TOKEN,
})
-const sharedListener = createSharedListener({
- client: sanityClient,
-})
+const sharedListener = createSharedListener({client: sanityClient})
const loadDocument = createDocumentLoader({client: sanityClient})
diff --git a/examples/web/package.json b/examples/web/package.json
index 6d80b1f..2f256d7 100644
--- a/examples/web/package.json
+++ b/examples/web/package.json
@@ -9,22 +9,22 @@
"start": "vite dev"
},
"dependencies": {
- "@sanity/client": "^6.21.1",
- "@sanity/icons": "^3.3.1",
- "@sanity/mutate": "workspace:",
+ "@sanity/client": "^6.21.3",
+ "@sanity/icons": "^3.4.0",
+ "@sanity/mutate": "workspace:^",
"@sanity/sanitype": "^0.6.2",
- "@sanity/ui": "^2.8.8",
+ "@sanity/ui": "^2.8.9",
"dataloader": "^2.2.2",
- "groq-js": "^1.12.0",
+ "groq-js": "^1.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-jason": "^1.1.2",
"rxjs": "^7.8.1",
- "styled-components": "^6.1.12"
+ "styled-components": "^6.1.13"
},
"devDependencies": {
"@types/node": "catalog:",
- "@types/react": "^18.3.3",
+ "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
@@ -39,6 +39,6 @@
"eslint-plugin-simple-import-sort": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"typescript": "catalog:",
- "vite": "^5.3.5"
+ "vite": "^5.4.2"
}
}
diff --git a/package.config.ts b/package.config.ts
index 316aea0..4b87099 100644
--- a/package.config.ts
+++ b/package.config.ts
@@ -2,6 +2,8 @@ import {defineConfig} from '@sanity/pkg-utils'
export default defineConfig({
extract: {
+ // Bundle mendoza typings, but don't bundle mendoza in the JS runtime
+ bundledPackages: ['mendoza'],
rules: {
'ae-missing-release-tag': 'off',
// do not require internal members to be prefixed with `_`
diff --git a/package.json b/package.json
index f323df0..9df4cbc 100644
--- a/package.json
+++ b/package.json
@@ -32,18 +32,24 @@
"require": "./dist/_path.cjs",
"default": "./dist/_path.js"
},
- "./_unstable_store": {
- "source": "./src/_unstable_store.ts",
- "import": "./dist/_unstable_store.js",
- "require": "./dist/_unstable_store.cjs",
- "default": "./dist/_unstable_store.js"
- },
"./_unstable_apply": {
"source": "./src/_unstable_apply.ts",
"import": "./dist/_unstable_apply.js",
"require": "./dist/_unstable_apply.cjs",
"default": "./dist/_unstable_apply.js"
},
+ "./_unstable_machine": {
+ "source": "./src/_unstable_machine.ts",
+ "import": "./dist/_unstable_machine.js",
+ "require": "./dist/_unstable_machine.cjs",
+ "default": "./dist/_unstable_machine.js"
+ },
+ "./_unstable_store": {
+ "source": "./src/_unstable_store.ts",
+ "import": "./dist/_unstable_store.js",
+ "require": "./dist/_unstable_store.cjs",
+ "default": "./dist/_unstable_store.js"
+ },
"./package.json": "./package.json"
},
"main": "./dist/index.cjs",
@@ -57,6 +63,9 @@
"_unstable_apply": [
"./dist/_unstable_apply.d.ts"
],
+ "_unstable_machine": [
+ "./dist/_unstable_machine.d.ts"
+ ],
"_unstable_store": [
"./dist/_unstable_store.d.ts"
]
@@ -64,9 +73,10 @@
},
"files": [
"dist",
- "test",
- "README.md",
- "package.json"
+ "_unstable_apply.js",
+ "_unstable_machine.js",
+ "_unstable_store.js",
+ "path.js"
],
"scripts": {
"build": "run-p pkg:build",
@@ -82,6 +92,7 @@
"test:watch": "vitest --typecheck",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage",
+ "example:visual-editing": "pnpm --filter example-visual-editing run dev",
"example:web": "pnpm --filter example-web run dev",
"check": "run-s typecheck pkg:build test",
"dev": "run-p pkg:watch",
@@ -114,6 +125,7 @@
"eslint-plugin-react-hooks": "catalog:",
"eslint-plugin-simple-import-sort": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
+ "lodash": "^4.17.21",
"npm-run-all2": "^5.0.2",
"prettier": "^3.4.1",
"rimraf": "^6.0.1",
@@ -121,6 +133,14 @@
"typescript": "catalog:",
"vitest": "^2.1.6"
},
+ "peerDependencies": {
+ "xstate": "^5.18.2"
+ },
+ "peerDependenciesMeta": {
+ "xstate": {
+ "optional": true
+ }
+ },
"packageManager": "pnpm@9.14.2",
"engines": {
"node": ">=18"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4009147..d38d350 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,6 +71,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.1
+ xstate:
+ specifier: ^5.18.2
+ version: 5.19.0
devDependencies:
'@sanity/pkg-utils':
specifier: ^6.11.13
@@ -181,29 +184,171 @@ importers:
specifier: ^2.3.0
version: 2.3.0
+ examples/visual-editing:
+ dependencies:
+ '@react-spring/three':
+ specifier: ^9.7.5
+ version: 9.7.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0))(react@18.3.1)(three@0.169.0)
+ '@react-three/drei':
+ specifier: ^9.117.3
+ version: 9.117.3(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0))(@types/react@18.3.11)(@types/three@0.169.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0)(use-sync-external-store@1.2.2(react@18.3.1))
+ '@react-three/fiber':
+ specifier: ^8.17.10
+ version: 8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0)
+ '@sanity/client':
+ specifier: ^6.22.5
+ version: 6.22.5
+ '@sanity/comlink':
+ specifier: ^2.0.0
+ version: 2.0.0
+ '@sanity/icons':
+ specifier: ^3.5.0
+ version: 3.5.0(react@18.3.1)
+ '@sanity/mutate':
+ specifier: workspace:*
+ version: link:../..
+ '@sanity/sanitype':
+ specifier: ^0.6.2
+ version: 0.6.2
+ '@sanity/ui':
+ specifier: ^2.8.9
+ version: 2.8.26(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
+ '@statelyai/inspect':
+ specifier: ^0.4.0
+ version: 0.4.0(ws@8.18.0)(xstate@5.19.0)
+ '@types/three':
+ specifier: ^0.169.0
+ version: 0.169.0
+ '@xstate/react':
+ specifier: ^4.1.3
+ version: 4.1.3(@types/react@18.3.11)(react@18.3.1)(xstate@5.19.0)
+ comlink:
+ specifier: ^4.4.1
+ version: 4.4.2
+ groq-js:
+ specifier: ^1.13.0
+ version: 1.14.1
+ mendoza:
+ specifier: ^3.0.7
+ version: 3.0.7
+ nice-color-palettes:
+ specifier: ^4.0.0
+ version: 4.0.0
+ polished:
+ specifier: ^4.3.1
+ version: 4.3.1
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-colorful:
+ specifier: ^5.6.1
+ version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react-compiler-runtime:
+ specifier: beta
+ version: 19.0.0-beta-df7b47d-20241124(react@18.3.1)
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ react-jason:
+ specifier: ^1.1.2
+ version: 1.1.2(react@18.3.1)
+ rxjs:
+ specifier: ^7.8.1
+ version: 7.8.1
+ styled-components:
+ specifier: ^6.1.13
+ version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ three:
+ specifier: ^0.169.0
+ version: 0.169.0
+ xstate:
+ specifier: ^5.18.2
+ version: 5.19.0
+ devDependencies:
+ '@types/node':
+ specifier: 'catalog:'
+ version: 20.14.11
+ '@types/react':
+ specifier: ^18.3.11
+ version: 18.3.11
+ '@types/react-dom':
+ specifier: ^18.3.1
+ version: 18.3.1
+ '@typescript-eslint/eslint-plugin':
+ specifier: 'catalog:'
+ version: 8.13.0(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3)
+ '@typescript-eslint/parser':
+ specifier: 'catalog:'
+ version: 8.13.0(eslint@8.57.0)(typescript@5.6.3)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.2
+ version: 4.3.4(vite@5.4.11(@types/node@20.14.11)(terser@5.31.3))
+ babel-plugin-react-compiler:
+ specifier: beta
+ version: 19.0.0-beta-df7b47d-20241124
+ eslint:
+ specifier: 'catalog:'
+ version: 8.57.0
+ eslint-config-prettier:
+ specifier: 'catalog:'
+ version: 9.1.0(eslint@8.57.0)
+ eslint-config-sanity:
+ specifier: 'catalog:'
+ version: 7.1.2(@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3))(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0))(eslint-plugin-react-hooks@5.0.0(eslint@8.57.0))(eslint-plugin-react@7.37.2(eslint@8.57.0))(eslint@8.57.0)
+ eslint-plugin-import:
+ specifier: 'catalog:'
+ version: 2.29.1(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)
+ eslint-plugin-prettier:
+ specifier: 'catalog:'
+ version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.4.1)
+ eslint-plugin-react:
+ specifier: 'catalog:'
+ version: 7.37.2(eslint@8.57.0)
+ eslint-plugin-react-compiler:
+ specifier: beta
+ version: 19.0.0-beta-df7b47d-20241124(eslint@8.57.0)
+ eslint-plugin-react-hooks:
+ specifier: 'catalog:'
+ version: 5.0.0(eslint@8.57.0)
+ eslint-plugin-simple-import-sort:
+ specifier: 'catalog:'
+ version: 12.1.1(eslint@8.57.0)
+ eslint-plugin-unused-imports:
+ specifier: 'catalog:'
+ version: 3.2.0(@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)
+ typescript:
+ specifier: 'catalog:'
+ version: 5.6.3
+ vite:
+ specifier: ^5.4.9
+ version: 5.4.11(@types/node@20.14.11)(terser@5.31.3)
+ vitest:
+ specifier: ^2.1.3
+ version: 2.1.6(@types/node@20.14.11)(@vitest/ui@2.1.6)(terser@5.31.3)
+
examples/web:
dependencies:
'@sanity/client':
- specifier: ^6.21.1
+ specifier: ^6.21.3
version: 6.22.5
'@sanity/icons':
- specifier: ^3.3.1
- version: 3.3.1(react@18.3.1)
+ specifier: ^3.4.0
+ version: 3.5.0(react@18.3.1)
'@sanity/mutate':
- specifier: 'workspace:'
+ specifier: workspace:^
version: link:../..
'@sanity/sanitype':
specifier: ^0.6.2
version: 0.6.2
'@sanity/ui':
- specifier: ^2.8.8
- version: 2.8.8(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
+ specifier: ^2.8.9
+ version: 2.8.26(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
dataloader:
specifier: ^2.2.2
version: 2.2.2
groq-js:
- specifier: ^1.12.0
- version: 1.12.0
+ specifier: ^1.13.0
+ version: 1.14.1
react:
specifier: ^18.3.1
version: 18.3.1
@@ -217,18 +362,18 @@ importers:
specifier: ^7.8.1
version: 7.8.1
styled-components:
- specifier: ^6.1.12
- version: 6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: ^6.1.13
+ version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 20.14.11
'@types/react':
- specifier: ^18.3.3
+ specifier: ^18.3.5
version: 18.3.11
'@types/react-dom':
specifier: ^18.3.0
- version: 18.3.0
+ version: 18.3.1
'@typescript-eslint/eslint-plugin':
specifier: 'catalog:'
version: 8.13.0(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3)
@@ -237,7 +382,7 @@ importers:
version: 8.13.0(eslint@8.57.0)(typescript@5.6.3)
'@vitejs/plugin-react':
specifier: ^4.3.1
- version: 4.3.1(vite@5.3.5(@types/node@20.14.11)(terser@5.31.3))
+ version: 4.3.4(vite@5.4.11(@types/node@20.14.11)(terser@5.31.3))
eslint:
specifier: 'catalog:'
version: 8.57.0
@@ -269,8 +414,8 @@ importers:
specifier: 'catalog:'
version: 5.6.3
vite:
- specifier: ^5.3.5
- version: 5.3.5(@types/node@20.14.11)(terser@5.31.3)
+ specifier: ^5.4.2
+ version: 5.4.11(@types/node@20.14.11)(terser@5.31.3)
packages:
@@ -383,14 +528,20 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-transform-react-jsx-self@7.24.7':
- resolution: {integrity: sha512-fOPQYbGSgH0HUp4UJO4sMBFjY6DuWq+2i8rixyUMb3CdGixs/gccURvYOAhajBdKDoGajFr3mUq5rH3phtkGzw==}
+ '@babel/plugin-transform-private-methods@7.25.9':
+ resolution: {integrity: sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-transform-react-jsx-source@7.24.7':
- resolution: {integrity: sha512-J2z+MWzZHVOemyLweMqngXrgGC42jQ//R0KdxqkIz/OrbVIIlhFI3WigZ5fO+nwFvBlncr4MGapd8vTyc7RPNQ==}
+ '@babel/plugin-transform-react-jsx-self@7.25.9':
+ resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.25.9':
+ resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -407,6 +558,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.26.0':
+ resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.25.9':
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
engines: {node: '>=6.9.0'}
@@ -887,8 +1042,8 @@ packages:
'@floating-ui/dom@1.6.8':
resolution: {integrity: sha512-kx62rP19VZ767Q653wsP1XZCGIirkE09E0QUGNYTM/ttbbQHqcGPdSfWFxUyyNLc/W6aoJRBajOSXhP6GXjC0Q==}
- '@floating-ui/react-dom@2.1.1':
- resolution: {integrity: sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==}
+ '@floating-ui/react-dom@2.1.2':
+ resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
@@ -938,6 +1093,9 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ '@mediapipe/tasks-vision@0.10.17':
+ resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==}
+
'@microsoft/api-extractor-model@7.30.0':
resolution: {integrity: sha512-26/LJZBrsWDKAkOWRiQbdVgcfd1F3nyJnAiJzsAgpouPk7LtOIj7PK9aJtBaw/pUXrkotEg27RrT+Jm/q0bbug==}
@@ -951,6 +1109,11 @@ packages:
'@microsoft/tsdoc@0.15.1':
resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==}
+ '@monogrid/gainmap-js@3.0.6':
+ resolution: {integrity: sha512-ireqJg7cw0tUn/JePDG8rAL7RyXgUKSDbjYdiygkrnye1WuKGLAWDBwF/ICwCwJ9iZBAF5caU8gSu+c34HLGdQ==}
+ peerDependencies:
+ three: '>= 0.159.0'
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -996,6 +1159,70 @@ packages:
'@polka/url@1.0.0-next.25':
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
+ '@react-spring/animated@9.7.5':
+ resolution: {integrity: sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+
+ '@react-spring/core@9.7.5':
+ resolution: {integrity: sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+
+ '@react-spring/rafz@9.7.5':
+ resolution: {integrity: sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==}
+
+ '@react-spring/shared@9.7.5':
+ resolution: {integrity: sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+
+ '@react-spring/three@9.7.5':
+ resolution: {integrity: sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==}
+ peerDependencies:
+ '@react-three/fiber': '>=6.0'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ three: '>=0.126'
+
+ '@react-spring/types@9.7.5':
+ resolution: {integrity: sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==}
+
+ '@react-three/drei@9.117.3':
+ resolution: {integrity: sha512-SnL8d17qO1cFXGVlDHhp+Oa9VZPwwOeibLHri5KNRARKOPv2+R71Sl84RTU9samBI2+1EaGJFciDvRgQnq+JOA==}
+ peerDependencies:
+ '@react-three/fiber': '>=8.0'
+ react: '>=18.0'
+ react-dom: '>=18.0'
+ three: '>=0.137'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ '@react-three/fiber@8.17.10':
+ resolution: {integrity: sha512-S6bqa4DqUooEkInYv/W+Jklv2zjSYCXAhm6qKpAQyOXhTEt5gBXnA7W6aoJ0bjmp9pAeaSj/AZUoz1HCSof/uA==}
+ peerDependencies:
+ expo: '>=43.0'
+ expo-asset: '>=8.4'
+ expo-file-system: '>=11.0'
+ expo-gl: '>=11.0'
+ react: '>=18.0'
+ react-dom: '>=18.0'
+ react-native: '>=0.64'
+ three: '>=0.133'
+ peerDependenciesMeta:
+ expo:
+ optional: true
+ expo-asset:
+ optional: true
+ expo-file-system:
+ optional: true
+ expo-gl:
+ optional: true
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+
'@rollup/plugin-alias@5.1.1':
resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==}
engines: {node: '>=14.0.0'}
@@ -1195,6 +1422,10 @@ packages:
resolution: {integrity: sha512-2TjYEvOftD0v7ukx3Csdh9QIu44P2z7NDJtlC3qITJRYV36J7R6Vfd3trVhFnN77/7CZrGjqngrtohv8VqO5nw==}
engines: {node: '>=18.0.0'}
+ '@sanity/comlink@2.0.0':
+ resolution: {integrity: sha512-HV668xtHdm7qz9V5FbqRi8/2l1GPJr7puh05KQeGMSRlRAkKwTeG6Y5zbZ0d/6zUTQf2SB1As725JdCfCJ3bdg==}
+ engines: {node: '>=18'}
+
'@sanity/diff-match-patch@3.1.1':
resolution: {integrity: sha512-dSZqGeYjHKGIkqAzGqLcG92LZyJGX+nYbs/FWawhBbTBDWi21kvQ0hsL3DJThuFVWtZMWTQijN3z6Cnd44Pf2g==}
engines: {node: '>=14.18'}
@@ -1202,8 +1433,8 @@ packages:
'@sanity/eventsource@5.0.2':
resolution: {integrity: sha512-/B9PMkUvAlUrpRq0y+NzXgRv5lYCLxZNsBJD2WXVnqZYOfByL9oQBV7KiTaARuObp5hcQYuPfOAVjgXe3hrixA==}
- '@sanity/icons@3.3.1':
- resolution: {integrity: sha512-5SYwRmqKpEVABUyLbSBC5Ffr21P2EvtWZtkqMCh3fiMpNMM3c56RFjdQBoPn2w1EuzJXSFit/ZTHMWAXMMlAwA==}
+ '@sanity/icons@3.5.0':
+ resolution: {integrity: sha512-OXZILmwd1lZybwO4RYSY3fr6Kn6MePWOjBJRWDiw5lu9W+DmYO87P82S8nD5y/toQvWr53eNQfRzob2PLo+ZOA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: ^18.3 || >=19.0.0-rc
@@ -1231,15 +1462,23 @@ packages:
'@sanity/types@3.63.0':
resolution: {integrity: sha512-d7Qbd07mzbvI6bEfb4j7LnDH5dAf5nwEyYPj+e8tHjlPYzyoutKQ5wmTU8rNqTmg4PiuvNYyDo6zWcEVrUW8Vw==}
- '@sanity/ui@2.8.8':
- resolution: {integrity: sha512-LeYpcng9fakvwgCtAV4b/2koCsm7TTDQNwK+r2MnVghH23ln0iblvBdO4+T1Q10E+m2Vr2dcy3+HErdTu8f8Ag==}
+ '@sanity/ui@2.8.26':
+ resolution: {integrity: sha512-QwlNQ8rh97CaMlyL/OHvNDEPHv7/HfjbPYl3lKywhv4P/SpbW43ftwVcCt7ipfIBrvHEDE5At2UGNbG8erB02w==}
engines: {node: '>=14.0.0'}
peerDependencies:
- react: ^18
- react-dom: ^18
- react-is: ^18
+ react: ^18 || >=19.0.0-0
+ react-dom: ^18 || >=19.0.0-0
+ react-is: ^18 || >=19.0.0-0
styled-components: ^5.2 || ^6
+ '@statelyai/inspect@0.4.0':
+ resolution: {integrity: sha512-VxQldRlKYcu6rzLY83RSXVwMYexkH6hNx85B89YWYyXYWtNGaWHFCwV7a/Kz8FFPeUz8EKVAnyMOg2kNpn07wQ==}
+ peerDependencies:
+ xstate: ^5.5.1
+
+ '@tweenjs/tween.js@23.1.3':
+ resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
+
'@types/argparse@1.0.38':
resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
@@ -1255,9 +1494,15 @@ packages:
'@types/babel__traverse@7.20.6':
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
+ '@types/debounce@1.2.4':
+ resolution: {integrity: sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==}
+
'@types/diff-match-patch@1.0.36':
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
+ '@types/draco3d@1.4.10':
+ resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
+
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
@@ -1288,6 +1533,9 @@ packages:
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
+ '@types/offscreencanvas@2019.7.3':
+ resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
+
'@types/parse-path@7.0.3':
resolution: {integrity: sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==}
@@ -1297,8 +1545,14 @@ packages:
'@types/prop-types@15.7.12':
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
- '@types/react-dom@18.3.0':
- resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==}
+ '@types/react-dom@18.3.1':
+ resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==}
+
+ '@types/react-reconciler@0.26.7':
+ resolution: {integrity: sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==}
+
+ '@types/react-reconciler@0.28.8':
+ resolution: {integrity: sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==}
'@types/react@18.3.11':
resolution: {integrity: sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==}
@@ -1306,12 +1560,21 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+ '@types/stats.js@0.17.3':
+ resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
+
'@types/stylis@4.2.5':
resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==}
+ '@types/three@0.169.0':
+ resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
+
'@types/unist@2.0.10':
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
+ '@types/webxr@0.5.20':
+ resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==}
+
'@typescript-eslint/eslint-plugin@8.13.0':
resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1372,11 +1635,19 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
- '@vitejs/plugin-react@4.3.1':
- resolution: {integrity: sha512-m/V2syj5CuVnaxcUJOQRel/Wr31FFXRFlnOoq1TVtkCxsY5veGMTEmpWHndrhB2U8ScHtCQB1e+4hWYExQc6Lg==}
+ '@use-gesture/core@10.3.1':
+ resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
+
+ '@use-gesture/react@10.3.1':
+ resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
+ peerDependencies:
+ react: '>= 16.8.0'
+
+ '@vitejs/plugin-react@4.3.4':
+ resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
- vite: ^4.2.0 || ^5.0.0
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0
'@vitest/coverage-v8@2.1.6':
resolution: {integrity: sha512-qItJVYDbG3MUFO68dOZUz+rWlqe9LMzotERXFXKg25s2A/kSVsyS9O0yNGrITfBd943GsnBeQZkBUu7Pc+zVeA==}
@@ -1421,6 +1692,18 @@ packages:
'@vitest/utils@2.1.6':
resolution: {integrity: sha512-ixNkFy3k4vokOUTU2blIUvOgKq/N2PW8vKIjZZYsGJCMX69MRa9J2sKqX5hY/k5O5Gty3YJChepkqZ3KM9LyIQ==}
+ '@webgpu/types@0.1.51':
+ resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
+
+ '@xstate/react@4.1.3':
+ resolution: {integrity: sha512-zhE+ZfrcCR87bu71Rkh5Z5ruZBivR/7uD/dkelzJqjQdI45IZc9DqTI8lL4Cg5+VN2p5k86KxDsusqW1kW11Tg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ xstate: ^5.18.2
+ peerDependenciesMeta:
+ xstate:
+ optional: true
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1530,9 +1813,18 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ babel-plugin-react-compiler@19.0.0-beta-df7b47d-20241124:
+ resolution: {integrity: sha512-93iSASR20HNsotcOTQ+KPL0zpgfRFVWL86AtXpmHp995HuMVnC9femd8Winr3GxkPEh8lEOyaw3nqY4q2HUm5w==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
@@ -1551,6 +1843,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ buffer@6.0.3:
+ resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -1566,6 +1861,11 @@ packages:
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
+ camera-controls@2.9.0:
+ resolution: {integrity: sha512-TpCujnP0vqPppTXXJRYpvIy0xq9Tro6jQf2iYUxlDpPCNxkvE/XGaTuwIxnhINOkVP/ob2CRYXtY3iVYXeMEzA==}
+ peerDependencies:
+ three: '>=0.126.1'
+
caniuse-lite@1.0.30001678:
resolution: {integrity: sha512-RR+4U/05gNtps58PEBDZcPWTgEO2MBeoPZ96aQcjmfkBWRIDfN451fW2qyDA9/+HohLLIL5GqiMwA+IB1pWarw==}
@@ -1601,6 +1901,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ comlink@4.4.2:
+ resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==}
+
comma-separated-tokens@1.0.8:
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
@@ -1619,9 +1922,18 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ copy-anything@3.0.5:
+ resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
+ engines: {node: '>=12.13'}
+
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+ cross-env@7.0.3:
+ resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
+ engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
+ hasBin: true
+
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -1654,6 +1966,9 @@ packages:
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+ debounce@1.2.1:
+ resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
+
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -1698,6 +2013,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ detect-gpu@5.0.58:
+ resolution: {integrity: sha512-LvGBf1NeLMEQhUAHkL+tQPVUxFLU4NWiPWEj35GB9GozOeIc+o9kCj5Zg/sSc795J5TpDty5DVc51y13RI5zkg==}
+
detect-indent@7.0.1:
resolution: {integrity: sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==}
engines: {node: '>=12.20'}
@@ -1718,6 +2036,9 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
+ draco3d@1.5.7:
+ resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
+
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -1871,6 +2192,12 @@ packages:
eslint-config-prettier:
optional: true
+ eslint-plugin-react-compiler@19.0.0-beta-df7b47d-20241124:
+ resolution: {integrity: sha512-82PfnllC8jP/68KdLAbpWuYTcfmtGLzkqy2IW85WopKMTr+4rdQpp+lfliQ/QE79wWrv/dRoADrk3Pdhq25nTw==}
+ engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0}
+ peerDependencies:
+ eslint: '>=7'
+
eslint-plugin-react-hooks@5.0.0:
resolution: {integrity: sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==}
engines: {node: '>=10'}
@@ -1950,6 +2277,10 @@ packages:
event-source-polyfill@1.0.31:
resolution: {integrity: sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==}
+ event-target-shim@6.0.2:
+ resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==}
+ engines: {node: '>=10.13.0'}
+
eventsource@2.0.2:
resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==}
engines: {node: '>=12.0.0'}
@@ -1974,6 +2305,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-safe-stringify@2.1.1:
+ resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
+
fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
@@ -1985,6 +2319,9 @@ packages:
picomatch:
optional: true
+ fflate@0.6.10:
+ resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
+
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@@ -2149,6 +2486,9 @@ packages:
resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ glsl-noise@0.0.0:
+ resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==}
+
gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
@@ -2161,8 +2501,8 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
- groq-js@1.12.0:
- resolution: {integrity: sha512-XxMg2qX3iX+WlhCkj4FmrtDaQKRot9RyphJ0UsmxI/l2aMOdvjG7pqF003yyXppjoE0Vk1ERCDCUYRvC3w/A2Q==}
+ groq-js@1.14.1:
+ resolution: {integrity: sha512-tUbxLKA2wDlcMGjTK/M3XizkvqPKIBL/Go+nbPVdw71zQtgWgftPXYi2CbmQ3TYvG+BDLIenlXn/xZDjVqbojw==}
engines: {node: '>= 14'}
has-bigints@1.0.2:
@@ -2197,6 +2537,15 @@ packages:
hastscript@6.0.0:
resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ hls.js@1.5.17:
+ resolution: {integrity: sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==}
+
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -2206,10 +2555,16 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ ieee754@1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+
ignore@5.3.1:
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
engines: {node: '>= 4'}
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@@ -2328,6 +2683,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
+ is-promise@2.2.2:
+ resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
+
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@@ -2373,6 +2731,10 @@ packages:
resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==}
engines: {node: '>= 0.4'}
+ is-what@4.1.16:
+ resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
+ engines: {node: '>=12.13'}
+
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -2382,6 +2744,11 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isomorphic-ws@5.0.0:
+ resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
+ peerDependencies:
+ ws: '*'
+
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -2402,6 +2769,11 @@ packages:
resolution: {integrity: sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==}
engines: {node: '>= 0.4'}
+ its-fine@1.2.5:
+ resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==}
+ peerDependencies:
+ react: '>=18.0'
+
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -2469,6 +2841,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -2507,6 +2882,12 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
+ maath@0.10.8:
+ resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==}
+ peerDependencies:
+ '@types/three': '>=0.134.0'
+ three: '>=0.134.0'
+
magic-string@0.30.12:
resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==}
@@ -2529,6 +2910,14 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
+ meshline@3.3.1:
+ resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==}
+ peerDependencies:
+ three: '>=0.137'
+
+ meshoptimizer@0.18.1:
+ resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
+
micromatch@4.0.7:
resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
engines: {node: '>=8.6'}
@@ -2591,6 +2980,9 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ nice-color-palettes@4.0.0:
+ resolution: {integrity: sha512-lP/4LoL0Q9L9JX1UMb80BYbRCqZPnLqzuBCPI7mpqIw1HQjt/jk/U8SJF28EmudAZkJ5zG65x2HYla9wvmdxbw==}
+
node-releases@2.0.18:
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
@@ -2693,6 +3085,9 @@ packages:
resolution: {integrity: sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==}
engines: {node: '>=14.13.0'}
+ partysocket@0.0.25:
+ resolution: {integrity: sha512-1oCGA65fydX/FgdnsiBh68buOvfxuteoZVSb3Paci2kRp/7lhF0HyA8EDb5X/O6FxId1e+usPTQNRuzFEvkJbQ==}
+
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
@@ -2751,6 +3146,10 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
+ polished@4.3.1:
+ resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
+ engines: {node: '>=10'}
+
possible-typed-array-names@1.0.0:
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
engines: {node: '>= 0.4'}
@@ -2762,10 +3161,13 @@ packages:
resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.4.40:
- resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==}
+ postcss@8.4.49:
+ resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
+ potpack@1.0.2:
+ resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -2801,6 +3203,9 @@ packages:
progress-stream@2.0.0:
resolution: {integrity: sha512-xJwOWR46jcXUq6EH9yYyqp+I52skPySOeHfkxOZ2IY1AiBi/sFJhbhAKHoV3OTw/omQ45KTio9215dRJ2Yxd3Q==}
+ promise-worker-transferable@1.0.4:
+ resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
+
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@@ -2831,6 +3236,22 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ react-colorful@5.6.1:
+ resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ react-compiler-runtime@19.0.0-beta-df7b47d-20241124:
+ resolution: {integrity: sha512-HLFbEf5rEhynZNxI/f1y26Hw0SCvFWh9aS0gCaDndak202oOAvRhy0qsUhmVyaeuRYqIxvPeltMvqDfvO+9/Fw==}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ react-composer@5.0.3:
+ resolution: {integrity: sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==}
+ peerDependencies:
+ react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
+
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -2848,6 +3269,12 @@ packages:
peerDependencies:
react: '>=16'
+ react-reconciler@0.27.0:
+ resolution: {integrity: sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==}
+ engines: {node: '>=0.10.0'}
+ peerDependencies:
+ react: ^18.0.0
+
react-refractor@2.2.0:
resolution: {integrity: sha512-UvWkBVqH/2b9nkkkt4UNFtU3aY1orQfd4plPjx5rxbefy6vGajNHU9n+tv8CbykFyVirr3vEBfN2JTxyK0d36g==}
peerDependencies:
@@ -2883,6 +3310,9 @@ packages:
refractor@3.6.0:
resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
+ regenerator-runtime@0.14.1:
+ resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
+
regexp.prototype.flags@1.5.2:
resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==}
engines: {node: '>= 0.4'}
@@ -2965,6 +3395,13 @@ packages:
resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==}
engines: {node: '>= 0.4'}
+ safe-stable-stringify@2.5.0:
+ resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
+ engines: {node: '>=10'}
+
+ scheduler@0.21.0:
+ resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==}
+
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -3047,8 +3484,8 @@ packages:
resolution: {integrity: sha512-d76wfhgUuGypKqY72Unm5LFnMpACbdxXsLPcL27pOsSrmVqH3PztFp1uq+Z22suk15h7vXmTesuh2aEjdCqb5w==}
hasBin: true
- source-map-js@1.2.0:
- resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map-support@0.5.21:
@@ -3082,6 +3519,15 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ stats-gl@2.4.2:
+ resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
+ peerDependencies:
+ '@types/three': '*'
+ three: '*'
+
+ stats.js@0.17.0:
+ resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==}
+
std-env@3.8.0:
resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==}
@@ -3138,8 +3584,8 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
- styled-components@6.1.12:
- resolution: {integrity: sha512-n/O4PzRPhbYI0k1vKKayfti3C/IGcPf+DqcrOB7O/ab9x4u/zjqraneT5N45+sIe87cxrCApXM8Bna7NYxwoTA==}
+ styled-components@6.1.13:
+ resolution: {integrity: sha512-M0+N2xSnAtwcVAQeFEsGWFFxXDftHUD7XrKla06QbpUMmbmtFBMMTcKWvFXtWxuD5qQkB8iU5gk6QASlx2ZRMw==}
engines: {node: '>= 16'}
peerDependencies:
react: '>= 16.8.0'
@@ -3148,6 +3594,10 @@ packages:
stylis@4.3.2:
resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==}
+ superjson@1.13.3:
+ resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
+ engines: {node: '>=10'}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -3160,6 +3610,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ suspend-react@0.1.3:
+ resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
+ peerDependencies:
+ react: '>=17.0'
+
synckit@0.9.2:
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -3176,6 +3631,20 @@ packages:
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
+ three-mesh-bvh@0.7.8:
+ resolution: {integrity: sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==}
+ deprecated: Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.
+ peerDependencies:
+ three: '>= 0.151.0'
+
+ three-stdlib@2.34.0:
+ resolution: {integrity: sha512-U5qJYWgUKBFJqr1coMSbczA964uvouzBjQbtJlaI9LfMwy7hr+kc1Mfh0gqi/2872KmGu9utgff6lj8Oti8+VQ==}
+ peerDependencies:
+ three: '>=0.128.0'
+
+ three@0.169.0:
+ resolution: {integrity: sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==}
+
through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
@@ -3216,6 +3685,19 @@ packages:
resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==}
engines: {node: '>=0.6'}
+ troika-three-text@0.52.2:
+ resolution: {integrity: sha512-UGYwjKnR8RgmyOIpo0/KiSW0wySQ155BQXNLoSWA1liKzXG+RyHM+dvTIDawHGVQcqjqyunFlVY32xm/HDqjpw==}
+ peerDependencies:
+ three: '>=0.125.0'
+
+ troika-three-utils@0.52.0:
+ resolution: {integrity: sha512-00oxqIIehtEKInOTQekgyknBuRUj1POfOUE2q1OmL+Xlpp4gIu+S0oA0schTyXsDS4d9DkR04iqCdD40rF5R6w==}
+ peerDependencies:
+ three: '>=0.125.0'
+
+ troika-worker-utils@0.52.0:
+ resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==}
+
ts-api-utils@1.3.0:
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
engines: {node: '>=16'}
@@ -3239,6 +3721,9 @@ packages:
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+ tunnel-rat@0.1.2:
+ resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
+
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -3317,6 +3802,20 @@ packages:
peerDependencies:
react: ^18.3 || ^19.0.0-0
+ use-isomorphic-layout-effect@1.1.2:
+ resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sync-external-store@1.2.2:
+ resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+
user-home@2.0.0:
resolution: {integrity: sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==}
engines: {node: '>=0.10.0'}
@@ -3324,10 +3823,22 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ utility-types@3.11.0:
+ resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==}
+ engines: {node: '>= 4'}
+
+ uuid@10.0.0:
+ resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+ hasBin: true
+
uuid@11.0.3:
resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
hasBin: true
+ uuid@9.0.1:
+ resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+ hasBin: true
+
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@@ -3336,8 +3847,8 @@ packages:
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
- vite@5.3.5:
- resolution: {integrity: sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==}
+ vite@5.4.11:
+ resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -3345,6 +3856,7 @@ packages:
less: '*'
lightningcss: ^1.21.0
sass: '*'
+ sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
@@ -3357,6 +3869,8 @@ packages:
optional: true
sass:
optional: true
+ sass-embedded:
+ optional: true
stylus:
optional: true
sugarss:
@@ -3389,6 +3903,12 @@ packages:
jsdom:
optional: true
+ webgl-constants@1.1.1:
+ resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==}
+
+ webgl-sdf-generator@1.1.1:
+ resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==}
+
which-boxed-primitive@1.0.2:
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
@@ -3429,6 +3949,21 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ ws@8.18.0:
+ resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ xstate@5.19.0:
+ resolution: {integrity: sha512-Juh1MjeRaVWr1IRxXYvQMMRFMrei6vq6+AfP6Zk9D9YV0ZuvubN0aM6s2ITwUrq+uWtP1NTO8kOZmsM/IqeOiQ==}
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -3452,6 +3987,48 @@ packages:
zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
+ zustand@3.7.2:
+ resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ react: '>=16.8'
+ peerDependenciesMeta:
+ react:
+ optional: true
+
+ zustand@4.5.5:
+ resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+
+ zustand@5.0.1:
+ resolution: {integrity: sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
snapshots:
'@ampproject/remapping@2.3.0':
@@ -3606,12 +4183,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/plugin-transform-react-jsx-self@7.24.7(@babel/core@7.26.0)':
+ '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.26.0)':
+ dependencies:
+ '@babel/core': 7.26.0
+ '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0)
+ '@babel/helper-plugin-utils': 7.25.9
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.0)':
dependencies:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
- '@babel/plugin-transform-react-jsx-source@7.24.7(@babel/core@7.26.0)':
+ '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.0)':
dependencies:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
@@ -3638,6 +4223,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/runtime@7.26.0':
+ dependencies:
+ regenerator-runtime: 0.14.1
+
'@babel/template@7.25.9':
dependencies:
'@babel/code-frame': 7.26.2
@@ -3924,7 +4513,7 @@ snapshots:
'@floating-ui/core': 1.6.5
'@floating-ui/utils': 0.2.5
- '@floating-ui/react-dom@2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.6.8
react: 18.3.1
@@ -3977,6 +4566,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
+ '@mediapipe/tasks-vision@0.10.17': {}
+
'@microsoft/api-extractor-model@7.30.0(@types/node@22.0.2)':
dependencies:
'@microsoft/tsdoc': 0.15.1
@@ -4012,6 +4603,11 @@ snapshots:
'@microsoft/tsdoc@0.15.1': {}
+ '@monogrid/gainmap-js@3.0.6(three@0.169.0)':
+ dependencies:
+ promise-worker-transferable: 1.0.4
+ three: 0.169.0
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4054,6 +4650,94 @@ snapshots:
'@polka/url@1.0.0-next.25': {}
+ '@react-spring/animated@9.7.5(react@18.3.1)':
+ dependencies:
+ '@react-spring/shared': 9.7.5(react@18.3.1)
+ '@react-spring/types': 9.7.5
+ react: 18.3.1
+
+ '@react-spring/core@9.7.5(react@18.3.1)':
+ dependencies:
+ '@react-spring/animated': 9.7.5(react@18.3.1)
+ '@react-spring/shared': 9.7.5(react@18.3.1)
+ '@react-spring/types': 9.7.5
+ react: 18.3.1
+
+ '@react-spring/rafz@9.7.5': {}
+
+ '@react-spring/shared@9.7.5(react@18.3.1)':
+ dependencies:
+ '@react-spring/rafz': 9.7.5
+ '@react-spring/types': 9.7.5
+ react: 18.3.1
+
+ '@react-spring/three@9.7.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0))(react@18.3.1)(three@0.169.0)':
+ dependencies:
+ '@react-spring/animated': 9.7.5(react@18.3.1)
+ '@react-spring/core': 9.7.5(react@18.3.1)
+ '@react-spring/shared': 9.7.5(react@18.3.1)
+ '@react-spring/types': 9.7.5
+ '@react-three/fiber': 8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0)
+ react: 18.3.1
+ three: 0.169.0
+
+ '@react-spring/types@9.7.5': {}
+
+ '@react-three/drei@9.117.3(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0))(@types/react@18.3.11)(@types/three@0.169.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0)(use-sync-external-store@1.2.2(react@18.3.1))':
+ dependencies:
+ '@babel/runtime': 7.26.0
+ '@mediapipe/tasks-vision': 0.10.17
+ '@monogrid/gainmap-js': 3.0.6(three@0.169.0)
+ '@react-spring/three': 9.7.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0))(react@18.3.1)(three@0.169.0)
+ '@react-three/fiber': 8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0)
+ '@use-gesture/react': 10.3.1(react@18.3.1)
+ camera-controls: 2.9.0(three@0.169.0)
+ cross-env: 7.0.3
+ detect-gpu: 5.0.58
+ glsl-noise: 0.0.0
+ hls.js: 1.5.17
+ maath: 0.10.8(@types/three@0.169.0)(three@0.169.0)
+ meshline: 3.3.1(three@0.169.0)
+ react: 18.3.1
+ react-composer: 5.0.3(react@18.3.1)
+ stats-gl: 2.4.2(@types/three@0.169.0)(three@0.169.0)
+ stats.js: 0.17.0
+ suspend-react: 0.1.3(react@18.3.1)
+ three: 0.169.0
+ three-mesh-bvh: 0.7.8(three@0.169.0)
+ three-stdlib: 2.34.0(three@0.169.0)
+ troika-three-text: 0.52.2(three@0.169.0)
+ tunnel-rat: 0.1.2(@types/react@18.3.11)(react@18.3.1)
+ utility-types: 3.11.0
+ uuid: 9.0.1
+ zustand: 5.0.1(@types/react@18.3.11)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1))
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/three'
+ - immer
+ - use-sync-external-store
+
+ '@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0)':
+ dependencies:
+ '@babel/runtime': 7.26.0
+ '@types/debounce': 1.2.4
+ '@types/react-reconciler': 0.26.7
+ '@types/webxr': 0.5.20
+ base64-js: 1.5.1
+ buffer: 6.0.3
+ debounce: 1.2.1
+ its-fine: 1.2.5(react@18.3.1)
+ react: 18.3.1
+ react-reconciler: 0.27.0(react@18.3.1)
+ scheduler: 0.21.0
+ suspend-react: 0.1.3(react@18.3.1)
+ three: 0.169.0
+ zustand: 3.7.2(react@18.3.1)
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+
'@rollup/plugin-alias@5.1.1(rollup@4.27.4)':
optionalDependencies:
rollup: 4.27.4
@@ -4220,6 +4904,12 @@ snapshots:
'@sanity/color@3.0.6': {}
+ '@sanity/comlink@2.0.0':
+ dependencies:
+ rxjs: 7.8.1
+ uuid: 10.0.0
+ xstate: 5.19.0
+
'@sanity/diff-match-patch@3.1.1': {}
'@sanity/eventsource@5.0.2':
@@ -4229,7 +4919,7 @@ snapshots:
event-source-polyfill: 1.0.31
eventsource: 2.0.2
- '@sanity/icons@3.3.1(react@18.3.1)':
+ '@sanity/icons@3.5.0(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -4302,20 +4992,35 @@ snapshots:
transitivePeerDependencies:
- debug
- '@sanity/ui@2.8.8(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
+ '@sanity/ui@2.8.26(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
dependencies:
- '@floating-ui/react-dom': 2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sanity/color': 3.0.6
- '@sanity/icons': 3.3.1(react@18.3.1)
+ '@sanity/icons': 3.5.0(react@18.3.1)
csstype: 3.1.3
framer-motion: 11.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
+ react-compiler-runtime: 19.0.0-beta-df7b47d-20241124(react@18.3.1)
react-dom: 18.3.1(react@18.3.1)
react-is: 18.3.1
react-refractor: 2.2.0(react@18.3.1)
- styled-components: 6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ styled-components: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
use-effect-event: 1.0.2(react@18.3.1)
+ '@statelyai/inspect@0.4.0(ws@8.18.0)(xstate@5.19.0)':
+ dependencies:
+ fast-safe-stringify: 2.1.1
+ isomorphic-ws: 5.0.0(ws@8.18.0)
+ partysocket: 0.0.25
+ safe-stable-stringify: 2.5.0
+ superjson: 1.13.3
+ uuid: 9.0.1
+ xstate: 5.19.0
+ transitivePeerDependencies:
+ - ws
+
+ '@tweenjs/tween.js@23.1.3': {}
+
'@types/argparse@1.0.38': {}
'@types/babel__core@7.20.5':
@@ -4339,8 +5044,12 @@ snapshots:
dependencies:
'@babel/types': 7.26.0
+ '@types/debounce@1.2.4': {}
+
'@types/diff-match-patch@1.0.36': {}
+ '@types/draco3d@1.4.10': {}
+
'@types/estree@1.0.6': {}
'@types/event-source-polyfill@1.0.5': {}
@@ -4369,6 +5078,8 @@ snapshots:
'@types/normalize-package-data@2.4.4': {}
+ '@types/offscreencanvas@2019.7.3': {}
+
'@types/parse-path@7.0.3': {}
'@types/progress-stream@2.0.5':
@@ -4377,7 +5088,15 @@ snapshots:
'@types/prop-types@15.7.12': {}
- '@types/react-dom@18.3.0':
+ '@types/react-dom@18.3.1':
+ dependencies:
+ '@types/react': 18.3.11
+
+ '@types/react-reconciler@0.26.7':
+ dependencies:
+ '@types/react': 18.3.11
+
+ '@types/react-reconciler@0.28.8':
dependencies:
'@types/react': 18.3.11
@@ -4388,10 +5107,23 @@ snapshots:
'@types/resolve@1.20.2': {}
+ '@types/stats.js@0.17.3': {}
+
'@types/stylis@4.2.5': {}
+ '@types/three@0.169.0':
+ dependencies:
+ '@tweenjs/tween.js': 23.1.3
+ '@types/stats.js': 0.17.3
+ '@types/webxr': 0.5.20
+ '@webgpu/types': 0.1.51
+ fflate: 0.8.2
+ meshoptimizer: 0.18.1
+
'@types/unist@2.0.10': {}
+ '@types/webxr@0.5.20': {}
+
'@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.0(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3)':
dependencies:
'@eslint-community/regexpp': 4.11.0
@@ -4475,14 +5207,21 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
- '@vitejs/plugin-react@4.3.1(vite@5.3.5(@types/node@20.14.11)(terser@5.31.3))':
+ '@use-gesture/core@10.3.1': {}
+
+ '@use-gesture/react@10.3.1(react@18.3.1)':
+ dependencies:
+ '@use-gesture/core': 10.3.1
+ react: 18.3.1
+
+ '@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@20.14.11)(terser@5.31.3))':
dependencies:
'@babel/core': 7.26.0
- '@babel/plugin-transform-react-jsx-self': 7.24.7(@babel/core@7.26.0)
- '@babel/plugin-transform-react-jsx-source': 7.24.7(@babel/core@7.26.0)
+ '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0)
+ '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
- vite: 5.3.5(@types/node@20.14.11)(terser@5.31.3)
+ vite: 5.4.11(@types/node@20.14.11)(terser@5.31.3)
transitivePeerDependencies:
- supports-color
@@ -4511,13 +5250,21 @@ snapshots:
chai: 5.1.2
tinyrainbow: 1.2.0
- '@vitest/mocker@2.1.6(vite@5.3.5(@types/node@22.0.2)(terser@5.31.3))':
+ '@vitest/mocker@2.1.6(vite@5.4.11(@types/node@20.14.11)(terser@5.31.3))':
+ dependencies:
+ '@vitest/spy': 2.1.6
+ estree-walker: 3.0.3
+ magic-string: 0.30.12
+ optionalDependencies:
+ vite: 5.4.11(@types/node@20.14.11)(terser@5.31.3)
+
+ '@vitest/mocker@2.1.6(vite@5.4.11(@types/node@22.0.2)(terser@5.31.3))':
dependencies:
'@vitest/spy': 2.1.6
estree-walker: 3.0.3
magic-string: 0.30.12
optionalDependencies:
- vite: 5.3.5(@types/node@22.0.2)(terser@5.31.3)
+ vite: 5.4.11(@types/node@22.0.2)(terser@5.31.3)
'@vitest/pretty-format@2.1.6':
dependencies:
@@ -4547,7 +5294,7 @@ snapshots:
sirv: 3.0.0
tinyglobby: 0.2.10
tinyrainbow: 1.2.0
- vitest: 2.1.6(@types/node@22.0.2)(@vitest/ui@2.1.6)(terser@5.31.3)
+ vitest: 2.1.6(@types/node@20.14.11)(@vitest/ui@2.1.6)(terser@5.31.3)
'@vitest/utils@2.1.6':
dependencies:
@@ -4555,6 +5302,18 @@ snapshots:
loupe: 3.1.2
tinyrainbow: 1.2.0
+ '@webgpu/types@0.1.51': {}
+
+ '@xstate/react@4.1.3(@types/react@18.3.11)(react@18.3.1)(xstate@5.19.0)':
+ dependencies:
+ react: 18.3.1
+ use-isomorphic-layout-effect: 1.1.2(@types/react@18.3.11)(react@18.3.1)
+ use-sync-external-store: 1.2.2(react@18.3.1)
+ optionalDependencies:
+ xstate: 5.19.0
+ transitivePeerDependencies:
+ - '@types/react'
+
acorn-jsx@5.3.2(acorn@8.12.1):
dependencies:
acorn: 8.12.1
@@ -4685,8 +5444,18 @@ snapshots:
dependencies:
possible-typed-array-names: 1.0.0
+ babel-plugin-react-compiler@19.0.0-beta-df7b47d-20241124:
+ dependencies:
+ '@babel/types': 7.26.0
+
balanced-match@1.0.2: {}
+ base64-js@1.5.1: {}
+
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
brace-expansion@1.1.11:
dependencies:
balanced-match: 1.0.2
@@ -4709,6 +5478,11 @@ snapshots:
buffer-from@1.1.2: {}
+ buffer@6.0.3:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
cac@6.7.14: {}
call-bind@1.0.7:
@@ -4723,6 +5497,10 @@ snapshots:
camelize@1.0.1: {}
+ camera-controls@2.9.0(three@0.169.0):
+ dependencies:
+ three: 0.169.0
+
caniuse-lite@1.0.30001678: {}
chai@5.1.2:
@@ -4756,6 +5534,8 @@ snapshots:
color-name@1.1.4: {}
+ comlink@4.4.2: {}
+
comma-separated-tokens@1.0.8: {}
commander@2.20.3: {}
@@ -4771,8 +5551,16 @@ snapshots:
convert-source-map@2.0.0: {}
+ copy-anything@3.0.5:
+ dependencies:
+ is-what: 4.1.16
+
core-util-is@1.0.3: {}
+ cross-env@7.0.3:
+ dependencies:
+ cross-spawn: 7.0.3
+
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
@@ -4811,6 +5599,8 @@ snapshots:
date-fns@4.1.0: {}
+ debounce@1.2.1: {}
+
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -4843,6 +5633,10 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ detect-gpu@5.0.58:
+ dependencies:
+ webgl-constants: 1.1.1
+
detect-indent@7.0.1: {}
detect-newline@4.0.1: {}
@@ -4859,6 +5653,8 @@ snapshots:
dependencies:
esutils: 2.0.3
+ draco3d@1.5.7: {}
+
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.53: {}
@@ -5126,6 +5922,18 @@ snapshots:
optionalDependencies:
eslint-config-prettier: 9.1.0(eslint@8.57.0)
+ eslint-plugin-react-compiler@19.0.0-beta-df7b47d-20241124(eslint@8.57.0):
+ dependencies:
+ '@babel/core': 7.26.0
+ '@babel/parser': 7.26.2
+ '@babel/plugin-transform-private-methods': 7.25.9(@babel/core@7.26.0)
+ eslint: 8.57.0
+ hermes-parser: 0.25.1
+ zod: 3.23.8
+ zod-validation-error: 3.4.0(zod@3.23.8)
+ transitivePeerDependencies:
+ - supports-color
+
eslint-plugin-react-hooks@5.0.0(eslint@8.57.0):
dependencies:
eslint: 8.57.0
@@ -5243,6 +6051,8 @@ snapshots:
event-source-polyfill@1.0.31: {}
+ event-target-shim@6.0.2: {}
+
eventsource@2.0.2: {}
expect-type@1.1.0: {}
@@ -5263,6 +6073,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-safe-stringify@2.1.1: {}
+
fastq@1.17.1:
dependencies:
reusify: 1.0.4
@@ -5271,6 +6083,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
+ fflate@0.6.10: {}
+
fflate@0.8.2: {}
file-entry-cache@6.0.1:
@@ -5469,6 +6283,8 @@ snapshots:
merge2: 1.4.1
slash: 4.0.0
+ glsl-noise@0.0.0: {}
+
gopd@1.0.1:
dependencies:
get-intrinsic: 1.2.4
@@ -5479,7 +6295,7 @@ snapshots:
graphemer@1.4.0: {}
- groq-js@1.12.0:
+ groq-js@1.14.1:
dependencies:
debug: 4.3.7
transitivePeerDependencies:
@@ -5515,14 +6331,26 @@ snapshots:
property-information: 5.6.0
space-separated-tokens: 1.1.5
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ hls.js@1.5.17: {}
+
hosted-git-info@2.8.9: {}
hotscript@1.0.13: {}
html-escaper@2.0.2: {}
+ ieee754@1.2.1: {}
+
ignore@5.3.1: {}
+ immediate@3.0.6: {}
+
import-fresh@3.3.0:
dependencies:
parent-module: 1.0.1
@@ -5624,6 +6452,8 @@ snapshots:
is-plain-obj@4.1.0: {}
+ is-promise@2.2.2: {}
+
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.6
@@ -5668,12 +6498,18 @@ snapshots:
call-bind: 1.0.7
get-intrinsic: 1.2.4
+ is-what@4.1.16: {}
+
isarray@1.0.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
+ isomorphic-ws@5.0.0(ws@8.18.0):
+ dependencies:
+ ws: 8.18.0
+
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -5703,6 +6539,11 @@ snapshots:
reflect.getprototypeof: 1.0.6
set-function-name: 2.0.2
+ its-fine@1.2.5(react@18.3.1):
+ dependencies:
+ '@types/react-reconciler': 0.28.8
+ react: 18.3.1
+
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -5765,6 +6606,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
lines-and-columns@1.2.4: {}
locate-path@3.0.0:
@@ -5798,6 +6643,11 @@ snapshots:
dependencies:
yallist: 4.0.0
+ maath@0.10.8(@types/three@0.169.0)(three@0.169.0):
+ dependencies:
+ '@types/three': 0.169.0
+ three: 0.169.0
+
magic-string@0.30.12:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@@ -5806,7 +6656,7 @@ snapshots:
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
- source-map-js: 1.2.0
+ source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
@@ -5818,6 +6668,12 @@ snapshots:
merge2@1.4.1: {}
+ meshline@3.3.1(three@0.169.0):
+ dependencies:
+ three: 0.169.0
+
+ meshoptimizer@0.18.1: {}
+
micromatch@4.0.7:
dependencies:
braces: 3.0.3
@@ -5863,6 +6719,8 @@ snapshots:
natural-compare@1.4.0: {}
+ nice-color-palettes@4.0.0: {}
+
node-releases@2.0.18: {}
normalize-package-data@2.5.0:
@@ -5991,6 +6849,10 @@ snapshots:
'@types/parse-path': 7.0.3
parse-path: 7.0.0
+ partysocket@0.0.25:
+ dependencies:
+ event-target-shim: 6.0.2
+
path-exists@3.0.0: {}
path-exists@4.0.0: {}
@@ -6029,6 +6891,10 @@ snapshots:
dependencies:
find-up: 3.0.0
+ polished@4.3.1:
+ dependencies:
+ '@babel/runtime': 7.26.0
+
possible-typed-array-names@1.0.0: {}
postcss-value-parser@4.2.0: {}
@@ -6037,13 +6903,15 @@ snapshots:
dependencies:
nanoid: 3.3.8
picocolors: 1.1.1
- source-map-js: 1.2.0
+ source-map-js: 1.2.1
- postcss@8.4.40:
+ postcss@8.4.49:
dependencies:
nanoid: 3.3.8
picocolors: 1.1.1
- source-map-js: 1.2.0
+ source-map-js: 1.2.1
+
+ potpack@1.0.2: {}
prelude-ls@1.2.1: {}
@@ -6071,6 +6939,11 @@ snapshots:
speedometer: 1.0.0
through2: 2.0.5
+ promise-worker-transferable@1.0.4:
+ dependencies:
+ is-promise: 2.2.2
+ lie: 3.3.0
+
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@@ -6105,6 +6978,20 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
+ react-colorful@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ react-compiler-runtime@19.0.0-beta-df7b47d-20241124(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ react-composer@5.0.3(react@18.3.1):
+ dependencies:
+ prop-types: 15.8.1
+ react: 18.3.1
+
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -6119,6 +7006,12 @@ snapshots:
dependencies:
react: 18.3.1
+ react-reconciler@0.27.0(react@18.3.1):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.3.1
+ scheduler: 0.21.0
+
react-refractor@2.2.0(react@18.3.1):
dependencies:
react: 18.3.1
@@ -6175,6 +7068,8 @@ snapshots:
parse-entities: 2.0.0
prismjs: 1.27.0
+ regenerator-runtime@0.14.1: {}
+
regexp.prototype.flags@1.5.2:
dependencies:
call-bind: 1.0.7
@@ -6283,6 +7178,12 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.1.4
+ safe-stable-stringify@2.5.0: {}
+
+ scheduler@0.21.0:
+ dependencies:
+ loose-envify: 1.4.0
+
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
@@ -6365,7 +7266,7 @@ snapshots:
semver: 7.6.3
sort-object-keys: 1.1.3
- source-map-js@1.2.0: {}
+ source-map-js@1.2.1: {}
source-map-support@0.5.21:
dependencies:
@@ -6396,6 +7297,13 @@ snapshots:
stackback@0.0.2: {}
+ stats-gl@2.4.2(@types/three@0.169.0)(three@0.169.0):
+ dependencies:
+ '@types/three': 0.169.0
+ three: 0.169.0
+
+ stats.js@0.17.0: {}
+
std-env@3.8.0: {}
string-argv@0.3.2: {}
@@ -6469,7 +7377,7 @@ snapshots:
strip-json-comments@3.1.1: {}
- styled-components@6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@emotion/is-prop-valid': 1.2.2
'@emotion/unitless': 0.8.1
@@ -6485,6 +7393,10 @@ snapshots:
stylis@4.3.2: {}
+ superjson@1.13.3:
+ dependencies:
+ copy-anything: 3.0.5
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -6495,6 +7407,10 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ suspend-react@0.1.3(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
synckit@0.9.2:
dependencies:
'@pkgr/core': 0.1.1
@@ -6515,6 +7431,22 @@ snapshots:
text-table@0.2.0: {}
+ three-mesh-bvh@0.7.8(three@0.169.0):
+ dependencies:
+ three: 0.169.0
+
+ three-stdlib@2.34.0(three@0.169.0):
+ dependencies:
+ '@types/draco3d': 1.4.10
+ '@types/offscreencanvas': 2019.7.3
+ '@types/webxr': 0.5.20
+ draco3d: 1.5.7
+ fflate: 0.6.10
+ potpack: 1.0.2
+ three: 0.169.0
+
+ three@0.169.0: {}
+
through2@2.0.5:
dependencies:
readable-stream: 2.3.8
@@ -6545,6 +7477,20 @@ snapshots:
treeify@1.1.0: {}
+ troika-three-text@0.52.2(three@0.169.0):
+ dependencies:
+ bidi-js: 1.0.3
+ three: 0.169.0
+ troika-three-utils: 0.52.0(three@0.169.0)
+ troika-worker-utils: 0.52.0
+ webgl-sdf-generator: 1.1.1
+
+ troika-three-utils@0.52.0(three@0.169.0):
+ dependencies:
+ three: 0.169.0
+
+ troika-worker-utils@0.52.0: {}
+
ts-api-utils@1.3.0(typescript@5.6.3):
dependencies:
typescript: 5.6.3
@@ -6571,6 +7517,14 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
+ tunnel-rat@0.1.2(@types/react@18.3.11)(react@18.3.1):
+ dependencies:
+ zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+ - react
+
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -6655,60 +7609,131 @@ snapshots:
dependencies:
react: 18.3.1
+ use-isomorphic-layout-effect@1.1.2(@types/react@18.3.11)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.11
+
+ use-sync-external-store@1.2.2(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
user-home@2.0.0:
dependencies:
os-homedir: 1.0.2
util-deprecate@1.0.2: {}
+ utility-types@3.11.0: {}
+
+ uuid@10.0.0: {}
+
uuid@11.0.3: {}
+ uuid@9.0.1: {}
+
validate-npm-package-license@3.0.4:
dependencies:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
+ vite-node@2.1.6(@types/node@20.14.11)(terser@5.31.3):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.3.7
+ es-module-lexer: 1.5.4
+ pathe: 1.1.2
+ vite: 5.4.11(@types/node@20.14.11)(terser@5.31.3)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vite-node@2.1.6(@types/node@22.0.2)(terser@5.31.3):
dependencies:
cac: 6.7.14
debug: 4.3.7
es-module-lexer: 1.5.4
pathe: 1.1.2
- vite: 5.3.5(@types/node@22.0.2)(terser@5.31.3)
+ vite: 5.4.11(@types/node@22.0.2)(terser@5.31.3)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
+ - sass-embedded
- stylus
- sugarss
- supports-color
- terser
- vite@5.3.5(@types/node@20.14.11)(terser@5.31.3):
+ vite@5.4.11(@types/node@20.14.11)(terser@5.31.3):
dependencies:
esbuild: 0.21.5
- postcss: 8.4.40
+ postcss: 8.4.49
rollup: 4.27.4
optionalDependencies:
'@types/node': 20.14.11
fsevents: 2.3.3
terser: 5.31.3
- vite@5.3.5(@types/node@22.0.2)(terser@5.31.3):
+ vite@5.4.11(@types/node@22.0.2)(terser@5.31.3):
dependencies:
esbuild: 0.21.5
- postcss: 8.4.40
+ postcss: 8.4.49
rollup: 4.27.4
optionalDependencies:
'@types/node': 22.0.2
fsevents: 2.3.3
terser: 5.31.3
+ vitest@2.1.6(@types/node@20.14.11)(@vitest/ui@2.1.6)(terser@5.31.3):
+ dependencies:
+ '@vitest/expect': 2.1.6
+ '@vitest/mocker': 2.1.6(vite@5.4.11(@types/node@20.14.11)(terser@5.31.3))
+ '@vitest/pretty-format': 2.1.6
+ '@vitest/runner': 2.1.6
+ '@vitest/snapshot': 2.1.6
+ '@vitest/spy': 2.1.6
+ '@vitest/utils': 2.1.6
+ chai: 5.1.2
+ debug: 4.3.7
+ expect-type: 1.1.0
+ magic-string: 0.30.12
+ pathe: 1.1.2
+ std-env: 3.8.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.1
+ tinypool: 1.0.1
+ tinyrainbow: 1.2.0
+ vite: 5.4.11(@types/node@20.14.11)(terser@5.31.3)
+ vite-node: 2.1.6(@types/node@20.14.11)(terser@5.31.3)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 20.14.11
+ '@vitest/ui': 2.1.6(vitest@2.1.6)
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vitest@2.1.6(@types/node@22.0.2)(@vitest/ui@2.1.6)(terser@5.31.3):
dependencies:
'@vitest/expect': 2.1.6
- '@vitest/mocker': 2.1.6(vite@5.3.5(@types/node@22.0.2)(terser@5.31.3))
+ '@vitest/mocker': 2.1.6(vite@5.4.11(@types/node@22.0.2)(terser@5.31.3))
'@vitest/pretty-format': 2.1.6
'@vitest/runner': 2.1.6
'@vitest/snapshot': 2.1.6
@@ -6724,7 +7749,7 @@ snapshots:
tinyexec: 0.3.1
tinypool: 1.0.1
tinyrainbow: 1.2.0
- vite: 5.3.5(@types/node@22.0.2)(terser@5.31.3)
+ vite: 5.4.11(@types/node@22.0.2)(terser@5.31.3)
vite-node: 2.1.6(@types/node@22.0.2)(terser@5.31.3)
why-is-node-running: 2.3.0
optionalDependencies:
@@ -6735,11 +7760,16 @@ snapshots:
- lightningcss
- msw
- sass
+ - sass-embedded
- stylus
- sugarss
- supports-color
- terser
+ webgl-constants@1.1.1: {}
+
+ webgl-sdf-generator@1.1.1: {}
+
which-boxed-primitive@1.0.2:
dependencies:
is-bigint: 1.0.4
@@ -6803,6 +7833,10 @@ snapshots:
wrappy@1.0.2: {}
+ ws@8.18.0: {}
+
+ xstate@5.19.0: {}
+
xtend@4.0.2: {}
yallist@3.1.1: {}
@@ -6816,3 +7850,20 @@ snapshots:
zod: 3.23.8
zod@3.23.8: {}
+
+ zustand@3.7.2(react@18.3.1):
+ optionalDependencies:
+ react: 18.3.1
+
+ zustand@4.5.5(@types/react@18.3.11)(react@18.3.1):
+ dependencies:
+ use-sync-external-store: 1.2.2(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.11
+ react: 18.3.1
+
+ zustand@5.0.1(@types/react@18.3.11)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)):
+ optionalDependencies:
+ '@types/react': 18.3.11
+ react: 18.3.1
+ use-sync-external-store: 1.2.2(react@18.3.1)
diff --git a/src/_unstable_machine.ts b/src/_unstable_machine.ts
new file mode 100644
index 0000000..4422674
--- /dev/null
+++ b/src/_unstable_machine.ts
@@ -0,0 +1 @@
+export * from './machine'
diff --git a/src/machine/__tests__/documentMutatorMachine.test.ts b/src/machine/__tests__/documentMutatorMachine.test.ts
new file mode 100644
index 0000000..9af5967
--- /dev/null
+++ b/src/machine/__tests__/documentMutatorMachine.test.ts
@@ -0,0 +1,523 @@
+import {
+ type ListenEvent,
+ type MutationEvent,
+ type SanityClient,
+} from '@sanity/client'
+import {concat, delay, type Observable, of} from 'rxjs'
+import {describe, expect, test, vi} from 'vitest'
+import {createActor, waitFor} from 'xstate'
+
+import {documentMutatorMachine} from '../documentMutatorMachine'
+import {
+ expected,
+ initialSnapshot,
+ middleSnapshot,
+ mutationEvents,
+} from './mendozaFixtures'
+
+const id = 'foo'
+const createFakeClient = (
+ document:
+ | Record
+ | undefined
+ | Promise | undefined> = undefined,
+ observer: Observable>> = of({
+ type: 'welcome',
+ listenerName: 'xyz',
+ }),
+) => {
+ const client = {
+ getDocument: vi.fn().mockImplementation(() => Promise.resolve(document)),
+ listen: vi.fn().mockImplementation(() => observer),
+ } satisfies Pick
+ Object.assign(client, {withConfig: () => client})
+ return client as unknown as SanityClient
+}
+
+describe.runIf('withResolvers' in Promise)('observing documents', () => {
+ test('observing a document that does not exist on the backend', async () => {
+ const client = createFakeClient()
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+ const {context} = await waitFor(actor, state => state.hasTag('ready'))
+ expect(context).toMatchObject({
+ id,
+ local: undefined,
+ remote: undefined,
+ })
+ })
+
+ test('observing a document that exist on the backend', async () => {
+ const doc = {_id: id, _type: 'foo'}
+ const client = createFakeClient(doc)
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+ const {context} = await waitFor(actor, state => state.hasTag('ready'))
+ expect(context).toMatchObject({id, local: doc, remote: doc})
+ })
+
+ test("observing a document that doesn't exist initially, but later is created", async () => {
+ const doc = {_id: id, _type: 'foo'}
+ const client = createFakeClient(
+ undefined,
+ concat(
+ of({type: 'welcome' as const, listenerName: 'xyz'}),
+ of({
+ type: 'mutation' as const,
+ eventId: `tc4gfghO54pOTXYCOfNgyx#${id}`,
+ documentId: id,
+ transactionId: 'tc4gfghO54pOTXYCOfNgyx',
+ transition: 'appear' as const,
+ identity: 'p-Zl6P1Ubthnhn',
+ resultRev: 'tc4gfghO54pOTXYCOfNgyx',
+ timestamp: '2024-09-02T21:53:01.625426111Z',
+ visibility: 'transaction' as const,
+ mutations: [],
+ effects: {
+ apply: [
+ 0,
+ {
+ ...doc,
+ _createdAt: '2024-09-02T21:53:01Z',
+ _updatedAt: '2024-09-02T21:53:01Z',
+ },
+ ],
+ revert: [0, null],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ }).pipe(delay(10)),
+ ),
+ )
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ const {context} = await waitFor(
+ actor,
+ state => state.hasTag('ready') && state.context.remote !== undefined,
+ )
+ expect(context).toMatchObject({id, local: doc, remote: doc})
+ })
+
+ test("observing a document that doesn't exist initially, but later is created, much much later", async () => {
+ const doc = {_id: id, _type: 'foo'}
+ const client = createFakeClient()
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ await waitFor(actor, state => state.hasTag('ready'))
+
+ actor.send({
+ type: 'mutation' as const,
+ eventId: `tc4gfghO54pOTXYCOfNgyx#${id}`,
+ documentId: id,
+ transactionId: 'tc4gfghO54pOTXYCOfNgyx',
+ transition: 'appear' as const,
+ identity: 'p-Zl6P1Ubthnhn',
+ resultRev: 'tc4gfghO54pOTXYCOfNgyx',
+ timestamp: '2024-09-02T21:53:01.625426111Z',
+ visibility: 'transaction' as const,
+ mutations: [],
+ effects: {
+ apply: [
+ 0,
+ {
+ ...doc,
+ _createdAt: '2024-09-02T21:53:01Z',
+ _updatedAt: '2024-09-02T21:53:01Z',
+ },
+ ],
+ revert: [0, null],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ })
+
+ const {context} = await waitFor(
+ actor,
+ state => state.context.remote !== undefined,
+ )
+ expect(context).toMatchObject({id, local: doc, remote: doc})
+ })
+
+ test("observing a document that doesn't exist initially, but is created before the document is fetched", async () => {
+ const doc = {_id: id, _type: 'foo'}
+ const {resolve, promise} = Promise.withResolvers()
+ const client = createFakeClient(promise)
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ await waitFor(actor, state => state.matches('connected'))
+
+ // Send the mutation event before resolving the document promise
+ actor.send({
+ type: 'mutation' as const,
+ eventId: `tc4gfghO54pOTXYCOfNgyx#${id}`,
+ documentId: id,
+ transactionId: 'tc4gfghO54pOTXYCOfNgyx',
+ transition: 'appear' as const,
+ identity: 'p-Zl6P1Ubthnhn',
+ resultRev: 'tc4gfghO54pOTXYCOfNgyx',
+ timestamp: '2024-09-02T21:53:01.625426111Z',
+ visibility: 'transaction' as const,
+ mutations: [],
+ effects: {
+ apply: [
+ 0,
+ {
+ ...doc,
+ _createdAt: '2024-09-02T21:53:01Z',
+ _updatedAt: '2024-09-02T21:53:01Z',
+ },
+ ],
+ revert: [0, null],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ })
+ await waitFor(actor, state => state.context.mutationEvents.length === 1)
+ resolve(undefined)
+
+ const {context} = await waitFor(actor, state => state.hasTag('ready'))
+
+ expect(context).toMatchObject({id, local: doc, remote: doc})
+ })
+})
+describe('local mutations', () => {
+ test('mutating a document that does not exist on the backend', async () => {
+ const client = createFakeClient()
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+ await waitFor(actor, state => state.hasTag('ready'))
+
+ actor.send({
+ type: 'mutate',
+ mutations: [{type: 'create', document: {_id: id, _type: 'foo'}}],
+ })
+ const {context} = await waitFor(actor, state =>
+ state.matches({connected: {loaded: 'dirty'}}),
+ )
+ expect(context).toMatchObject({
+ id,
+ local: {_id: id, _type: 'foo'},
+ remote: undefined,
+ stagedChanges: [
+ {
+ transaction: false,
+ mutations: [
+ {
+ type: 'create',
+ document: {_id: id, _type: 'foo'},
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ test("observing a document that doesn't exist initially, but later is created locally", async () => {
+ const doc = {_id: id, _type: 'foo'}
+ const client = createFakeClient()
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ // Wait for initial remote snapshot fetch to resolve
+ await waitFor(actor, state => state.hasTag('ready'))
+
+ // Optimistically create a document locally
+ actor.send({
+ type: 'mutate',
+ mutations: [{type: 'createIfNotExists', document: doc}],
+ })
+
+ // Wait for mutations to be staged
+ expect(
+ (
+ await waitFor(actor, state =>
+ state.matches({connected: {loaded: 'dirty'}}),
+ )
+ ).context,
+ ).toMatchObject({
+ id,
+ local: {_id: 'foo', _type: 'foo'},
+ remote: undefined,
+ stagedChanges: [
+ {
+ transaction: false,
+ mutations: [
+ {
+ type: 'createIfNotExists',
+ document: {_id: 'foo', _type: 'foo'},
+ },
+ ],
+ },
+ ],
+ })
+
+ // Simulate a mendoza creation event from the server
+ actor.send({
+ type: 'mutation' as const,
+ eventId: `tc4gfghO54pOTXYCOfNgyx#${id}`,
+ documentId: id,
+ transactionId: 'tc4gfghO54pOTXYCOfNgyx',
+ transition: 'appear' as const,
+ identity: 'p-Zl6P1Ubthnhn',
+ resultRev: 'tc4gfghO54pOTXYCOfNgyx',
+ timestamp: '2024-09-02T21:53:01.625426111Z',
+ visibility: 'transaction' as const,
+ mutations: [],
+ effects: {
+ apply: [
+ 0,
+ {
+ ...doc,
+ _createdAt: '2024-09-02T21:53:01Z',
+ _updatedAt: '2024-09-02T21:53:01Z',
+ },
+ ],
+ revert: [0, null],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ })
+
+ expect(
+ (await waitFor(actor, state => state.context.remote !== undefined))
+ .context,
+ ).toMatchObject({
+ id,
+ local: {
+ _id: 'foo',
+ _type: 'foo',
+ _createdAt: '2024-09-02T21:53:01Z',
+ _updatedAt: '2024-09-02T21:53:01Z',
+ },
+ remote: {
+ ...doc,
+ _createdAt: '2024-09-02T21:53:01Z',
+ _updatedAt: '2024-09-02T21:53:01Z',
+ },
+ stagedChanges: [
+ {
+ transaction: false,
+ mutations: [
+ {
+ type: 'createIfNotExists',
+ document: {_id: 'foo', _type: 'foo'},
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ test("error when creating a document locally using 'create', when it turns out later that it exists on the server ", async () => {
+ expect.hasAssertions()
+ const doc = {_id: id, _type: 'foo'}
+ const {resolve, promise} = Promise.withResolvers()
+ const client = createFakeClient(promise)
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ // this will go through at first, but then we'll get an error an instant later during rebase after the document is loaded from the server
+ // this is expected, and will be similar to what would have happened if the mutation was sent directly to the server
+ // It might cause the document to appear for a brief time before the error is emitted though
+ // Typically, consumers should use `createIfNotExists` instead of `create` to avoid this
+ actor.send({
+ type: 'mutate',
+ mutations: [{type: 'create', document: doc}],
+ })
+
+ // resolve the document promise, which should trigger the error later
+ resolve(doc)
+
+ try {
+ // awaiting should trigger an error
+ await waitFor(actor, state => state.hasTag('ready'))
+ } catch (err) {
+ expect(err).toMatchInlineSnapshot(`[Error: Document already exist]`)
+ }
+ })
+})
+
+describe('remote mutations', () => {
+ test('it applies mendoza patches correctly', async () => {
+ const {resolve, promise} = Promise.withResolvers()
+ const client = createFakeClient(promise)
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ // Wait for the observer to emit welcome
+ await waitFor(actor, s => s.matches('connected'))
+
+ // Replay all mendoza events
+ for (const mutationEvent of mutationEvents) {
+ actor.send(mutationEvent as unknown as MutationEvent)
+ }
+
+ // Resolve the initial snapshot, it should match the `expected` snapshot
+ resolve(initialSnapshot)
+
+ const {context} = await waitFor(actor, s => s.hasTag('ready'))
+
+ expect(context.remote).toStrictEqual(expected)
+ })
+
+ test('it handles skipping mendoza patches that are already applied', async () => {
+ const {resolve, promise} = Promise.withResolvers()
+ const client = createFakeClient(promise)
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ // Wait for the observer to emit welcome
+ await waitFor(actor, s => s.matches('connected'))
+
+ // Replay all mendoza events, if none are filtered out, the test will fail
+ for (const mutationEvent of mutationEvents) {
+ actor.send(mutationEvent as unknown as MutationEvent)
+ }
+
+ // Resolve the middle snapshot, it should match the `expected` snapshot even though some patches were ignored
+ resolve(middleSnapshot)
+
+ const {context} = await waitFor(actor, s => s.hasTag('ready'))
+
+ expect(context.remote).toStrictEqual(expected)
+ })
+
+ test('it handles a document that is created after the initial snapshot', async () => {
+ const {resolve, promise} = Promise.withResolvers()
+ const client = createFakeClient(promise)
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ // Wait for the observer to emit welcome
+ await waitFor(actor, s => s.matches('connected'))
+
+ // Emulate case where a document does not exist yet
+ resolve(undefined)
+
+ await waitFor(actor, s => s.hasTag('ready'))
+
+ // Apply mendoza patch for the document creation
+ actor.send({
+ type: 'mutation',
+ eventId: 'P1yh4PWg0YACD6zrezLfAJ#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'P1yh4PWg0YACD6zrezLfAJ',
+ transition: 'appear',
+ identity: 'p-Zl6P1Ubthnhn',
+ resultRev: 'P1yh4PWg0YACD6zrezLfAJ',
+ timestamp: '2024-08-15T22:34:29.209906232Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 0,
+ {
+ _createdAt: '2024-08-15T22:34:29Z',
+ _id: 'shoe-a',
+ _type: 'shoe',
+ _updatedAt: '2024-08-15T22:34:29Z',
+ name: 'Foo',
+ },
+ ],
+ revert: [0, null],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ } as unknown as MutationEvent)
+
+ const {context} = await waitFor(actor, s => s.hasTag('ready'))
+
+ expect(context.remote).toStrictEqual({
+ _createdAt: '2024-08-15T22:34:29Z',
+ _id: 'shoe-a',
+ _rev: 'P1yh4PWg0YACD6zrezLfAJ',
+ _type: 'shoe',
+ _updatedAt: '2024-08-15T22:34:29Z',
+ name: 'Foo',
+ })
+ })
+
+ test('it handles disappearing documents', async () => {
+ const snapshot = {
+ _createdAt: '2024-08-15T22:52:23Z',
+ _id: 'shoe-a',
+ _type: 'shoe',
+ _updatedAt: '2024-08-15T22:52:23Z',
+ model: {_type: 'airmax'},
+ name: 'Test',
+ } as const
+ const {resolve, promise} = Promise.withResolvers()
+ const client = createFakeClient(promise)
+
+ const actor = createActor(documentMutatorMachine, {
+ input: {client, id},
+ }).start()
+
+ // Wait for the observer to emit welcome
+ await waitFor(actor, s => s.matches('connected'))
+
+ resolve(snapshot)
+
+ await waitFor(actor, s => s.hasTag('ready'))
+
+ // Apply mendoza patch for the document deletion
+ actor.send({
+ type: 'mutation',
+ eventId: 'P1yh4PWg0YACD6zrezM77i#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'P1yh4PWg0YACD6zrezM77i',
+ transition: 'disappear',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'SsKAgT2uMjbvUYECwj5WPZ',
+ resultRev: 'P1yh4PWg0YACD6zrezM77i',
+ timestamp: '2024-08-15T22:52:55.830602676Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [0, null],
+ revert: [
+ 0,
+ {
+ _createdAt: '2024-08-15T22:52:23Z',
+ _id: 'shoe-a',
+ _type: 'shoe',
+ _updatedAt: '2024-08-15T22:52:23Z',
+ model: {_type: 'airmax'},
+ name: 'Test',
+ },
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ } as unknown as MutationEvent)
+
+ const {context} = await waitFor(actor, s => s.hasTag('ready'))
+
+ // @TODO hmmmm, it's not ideal that `null` and `undefined` are used interchangeably
+ expect(context.local).toBe(undefined)
+ expect(context.remote).toBe(null)
+ })
+})
diff --git a/src/machine/__tests__/exports.test.ts b/src/machine/__tests__/exports.test.ts
new file mode 100644
index 0000000..c0d810c
--- /dev/null
+++ b/src/machine/__tests__/exports.test.ts
@@ -0,0 +1,14 @@
+import {expect, test} from 'vitest'
+
+import * as exports from '../index'
+
+test.each([
+ ['applyMutations'],
+ ['commit'],
+ ['rebase'],
+ ['squashDMPStrings'],
+ ['squashMutationGroups'],
+ ['toTransactions'],
+])('%s is not exported', key => {
+ expect((exports as Record)[key]).toBeUndefined()
+})
diff --git a/src/machine/__tests__/mendozaFixtures.ts b/src/machine/__tests__/mendozaFixtures.ts
new file mode 100644
index 0000000..162d473
--- /dev/null
+++ b/src/machine/__tests__/mendozaFixtures.ts
@@ -0,0 +1,1599 @@
+// The document snapshot before any mendoza events happened
+export const initialSnapshot = {
+ _createdAt: '2024-08-12T18:07:27Z',
+ _id: 'shoe-a',
+ _rev: 'Sw9sK32qHQa0pnKE3elJ2l',
+ _type: 'shoe',
+ _updatedAt: '2024-08-14T18:40:22Z',
+ model: {
+ _type: 'draco',
+ band: '#ff0',
+ caps: '#fff001',
+ inner: '#fffff1',
+ laces: '#123abd',
+ mesh: '#fff123',
+ patch: '#ff0',
+ sole: '#ff0ff00',
+ stripes: '#fffff0',
+ },
+ name: '🥰 Lunar Glide: Moon 🌙 Walking Sneakers 👟 abc 123 abc 123',
+}
+
+// The document snapshot in the middle of the mendoza events
+export const middleSnapshot = {
+ _createdAt: '2024-08-12T18:07:27Z',
+ _id: 'shoe-a',
+ _rev: 'heXa0IZkQ3K5JqXALr1dyq',
+ _type: 'shoe',
+ _updatedAt: '2024-08-14T18:56:23Z',
+ model: {
+ _type: 'draco',
+ band: '#ff0',
+ caps: '#fff001',
+ inner: '#fffff1',
+ laces: '#f0f',
+ mesh: '#ff0',
+ patch: '#fff',
+ sole: '#ff0ff00',
+ stripes: '#fffff0',
+ },
+ name: 'Lunar Glide: Moon 🌙 Walking Sneakers 👟',
+}
+
+export const expected = {
+ _createdAt: '2024-08-12T18:07:27Z',
+ _id: 'shoe-a',
+ _rev: 'q1XbfY2Gp42YDTcSgFsAQH',
+ _type: 'shoe',
+ _updatedAt: '2024-08-14T18:57:34Z',
+ model: {
+ _type: 'draco',
+ band: '#fff',
+ caps: '#fff000',
+ inner: '#fffff',
+ laces: '#fff',
+ mesh: '#f00',
+ patch: '#ffffff',
+ sole: '#ff0ff00',
+ stripes: '#fffff0',
+ },
+ name: 'Lunar Glide: Moon 🌙 Walking Sneakers 👟',
+}
+
+export const mutationEvents = [
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enOaL#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enOaL',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3elJ2l',
+ resultRev: 'Sw9sK32qHQa0pnKE3enOaL',
+ timestamp: '2024-08-14T18:55:28.861179292Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 14,
+ 22,
+ '55:28',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 5,
+ 65,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 14,
+ 22,
+ '40:22',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 22,
+ '🥰 ',
+ 23,
+ 0,
+ 60,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1a6G#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1a6G',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enOaL',
+ resultRev: 'heXa0IZkQ3K5JqXALr1a6G',
+ timestamp: '2024-08-14T18:55:31.815660055Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '31',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 23,
+ 22,
+ ' ',
+ 23,
+ 23,
+ 60,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '28',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 23,
+ 23,
+ 24,
+ 61,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enPfd#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enPfd',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1a6G',
+ resultRev: 'Sw9sK32qHQa0pnKE3enPfd',
+ timestamp: '2024-08-14T18:55:35.224892288Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '5',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 46,
+ 23,
+ 51,
+ 61,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '1',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 46,
+ 22,
+ 'abc 1',
+ 23,
+ 46,
+ 56,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enPu3#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enPu3',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enPfd',
+ resultRev: 'Sw9sK32qHQa0pnKE3enPu3',
+ timestamp: '2024-08-14T18:55:37.751848064Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '7',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 46,
+ 22,
+ '1',
+ 23,
+ 46,
+ 56,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '5',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 46,
+ 23,
+ 47,
+ 57,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'q1XbfY2Gp42YDTcSgFs2CG#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'q1XbfY2Gp42YDTcSgFs2CG',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enPu3',
+ resultRev: 'q1XbfY2Gp42YDTcSgFs2CG',
+ timestamp: '2024-08-14T18:55:41.034851816Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '41',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 50,
+ 23,
+ 54,
+ 57,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '37',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 50,
+ 22,
+ 'abc ',
+ 23,
+ 50,
+ 53,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1atE#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1atE',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'q1XbfY2Gp42YDTcSgFs2CG',
+ resultRev: 'heXa0IZkQ3K5JqXALr1atE',
+ timestamp: '2024-08-14T18:55:45.083113138Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '5',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 46,
+ 22,
+ '456',
+ 23,
+ 49,
+ 53,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '1',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 46,
+ 22,
+ '123',
+ 23,
+ 49,
+ 53,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'q1XbfY2Gp42YDTcSgFs2fy#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'q1XbfY2Gp42YDTcSgFs2fy',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1atE',
+ resultRev: 'q1XbfY2Gp42YDTcSgFs2fy',
+ timestamp: '2024-08-14T18:55:47.441104994Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [11, 3, 23, 0, 18, 22, '7', 23, 19, 20, 15, 11, 5, 23, 0, 45, 15],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '5',
+ 23,
+ 19,
+ 20,
+ 15,
+ 11,
+ 5,
+ 23,
+ 0,
+ 45,
+ 22,
+ ' 456 123',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'q1XbfY2Gp42YDTcSgFs2zm#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'q1XbfY2Gp42YDTcSgFs2zm',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'q1XbfY2Gp42YDTcSgFs2fy',
+ resultRev: 'q1XbfY2Gp42YDTcSgFs2zm',
+ timestamp: '2024-08-14T18:55:50.958373905Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '50',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff',
+ 'laces',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '47',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#123abd',
+ 'laces',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enSEF#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enSEF',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'q1XbfY2Gp42YDTcSgFs2zm',
+ resultRev: 'Sw9sK32qHQa0pnKE3enSEF',
+ timestamp: '2024-08-14T18:55:53.473582723Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '3',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#f0f',
+ 'laces',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '0',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff',
+ 'laces',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1bsy#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1bsy',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enSEF',
+ resultRev: 'heXa0IZkQ3K5JqXALr1bsy',
+ timestamp: '2024-08-14T18:55:57.009308495Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '7',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ff000',
+ 'mesh',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '3',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff123',
+ 'mesh',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enT0J#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enT0J',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1bsy',
+ resultRev: 'Sw9sK32qHQa0pnKE3enT0J',
+ timestamp: '2024-08-14T18:56:00.818697735Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 15,
+ 22,
+ '6:00',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 10,
+ 1,
+ 14,
+ 'mesh',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 15,
+ 22,
+ '5:57',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ff000',
+ 'mesh',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1cfw#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1cfw',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enT0J',
+ resultRev: 'heXa0IZkQ3K5JqXALr1cfw',
+ timestamp: '2024-08-14T18:56:04.226498248Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '4',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 2,
+ 23,
+ 0,
+ 6,
+ 22,
+ '0',
+ 15,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '0',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 2,
+ 23,
+ 0,
+ 6,
+ 22,
+ '1',
+ 15,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enTmN#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enTmN',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1cfw',
+ resultRev: 'Sw9sK32qHQa0pnKE3enTmN',
+ timestamp: '2024-08-14T18:56:06.894098980Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '6',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 2,
+ 23,
+ 0,
+ 6,
+ 22,
+ '1',
+ 15,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '4',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 2,
+ 23,
+ 0,
+ 6,
+ 22,
+ '0',
+ 15,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1d1E#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1d1E',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enTmN',
+ resultRev: 'heXa0IZkQ3K5JqXALr1d1E',
+ timestamp: '2024-08-14T18:56:09.462189405Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '9',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff01',
+ 'caps',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '6',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff001',
+ 'caps',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enUYR#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enUYR',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1d1E',
+ resultRev: 'Sw9sK32qHQa0pnKE3enUYR',
+ timestamp: '2024-08-14T18:56:12.041493436Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '12',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff001',
+ 'caps',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '09',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff01',
+ 'caps',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1ddY#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1ddY',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enUYR',
+ resultRev: 'heXa0IZkQ3K5JqXALr1ddY',
+ timestamp: '2024-08-14T18:56:18.074412276Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '8',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff',
+ 'patch',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '2',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 10,
+ 1,
+ 14,
+ 'patch',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1doC#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1doC',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1ddY',
+ resultRev: 'heXa0IZkQ3K5JqXALr1doC',
+ timestamp: '2024-08-14T18:56:21.293587203Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '21',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ff',
+ 'patch',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '18',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff',
+ 'patch',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1dyq#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1dyq',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1doC',
+ resultRev: 'heXa0IZkQ3K5JqXALr1dyq',
+ timestamp: '2024-08-14T18:56:23.536214525Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '3',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff',
+ 'patch',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '1',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ff',
+ 'patch',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1eOO#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1eOO',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1dyq',
+ resultRev: 'heXa0IZkQ3K5JqXALr1eOO',
+ timestamp: '2024-08-14T18:56:30.121805453Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '30',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ffffff',
+ 'patch',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '23',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 6,
+ 23,
+ 0,
+ 4,
+ 15,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'Sw9sK32qHQa0pnKE3enVwx#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'Sw9sK32qHQa0pnKE3enVwx',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1eOO',
+ resultRev: 'Sw9sK32qHQa0pnKE3enVwx',
+ timestamp: '2024-08-14T18:56:33.635259232Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '3',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#fff',
+ 'band',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '0',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 10,
+ 5,
+ 14,
+ 'band',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1elo#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1elo',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'Sw9sK32qHQa0pnKE3enVwx',
+ resultRev: 'heXa0IZkQ3K5JqXALr1elo',
+ timestamp: '2024-08-14T18:56:39.458670094Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '9',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 3,
+ 23,
+ 0,
+ 6,
+ 15,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '3',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 3,
+ 23,
+ 0,
+ 6,
+ 22,
+ '1',
+ 15,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'q1XbfY2Gp42YDTcSgFs5dB#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'q1XbfY2Gp42YDTcSgFs5dB',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1elo',
+ resultRev: 'q1XbfY2Gp42YDTcSgFs5dB',
+ timestamp: '2024-08-14T18:56:50.572506382Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '50',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 2,
+ 23,
+ 0,
+ 6,
+ 22,
+ '0',
+ 15,
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '39',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 2,
+ 23,
+ 0,
+ 6,
+ 22,
+ '1',
+ 15,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'q1XbfY2Gp42YDTcSgFs66t#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'q1XbfY2Gp42YDTcSgFs66t',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'q1XbfY2Gp42YDTcSgFs5dB',
+ resultRev: 'q1XbfY2Gp42YDTcSgFs66t',
+ timestamp: '2024-08-14T18:56:59.166408708Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '9',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ffff00',
+ 'mesh',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '0',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ff0',
+ 'mesh',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1g0S#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1g0S',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'q1XbfY2Gp42YDTcSgFs66t',
+ resultRev: 'heXa0IZkQ3K5JqXALr1g0S',
+ timestamp: '2024-08-14T18:57:11.368314479Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 15,
+ 22,
+ '7:11',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#f0f00',
+ 'laces',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 15,
+ 22,
+ '6:59',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 11,
+ 4,
+ 23,
+ 0,
+ 4,
+ 15,
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1gQ0#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1gQ0',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1g0S',
+ resultRev: 'heXa0IZkQ3K5JqXALr1gQ0',
+ timestamp: '2024-08-14T18:57:19.169846020Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '9',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#f0ffff',
+ 'laces',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 18,
+ 22,
+ '1',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#f0f00',
+ 'laces',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+
+ {
+ type: 'mutation',
+ eventId: 'heXa0IZkQ3K5JqXALr1hF6#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'heXa0IZkQ3K5JqXALr1hF6',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubt,hnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1gQ0',
+ resultRev: 'heXa0IZkQ3K5JqXALr1hF6',
+ timestamp: '2024-08-14T18:57:29.464391834Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '2',
+ 23,
+ 18,
+ 20,
+ 15,
+ 10,
+ 4,
+ 10,
+ 1,
+ 14,
+ 'laces',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '1',
+ 23,
+ 18,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#f0ffff',
+ 'laces',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+ {
+ type: 'mutation',
+ eventId: 'q1XbfY2Gp42YDTcSgFsAQH#shoe-a',
+ documentId: 'shoe-a',
+ transactionId: 'q1XbfY2Gp42YDTcSgFsAQH',
+ transition: 'update',
+ identity: 'p-Zl6P1Ubthnhn',
+ previousRev: 'heXa0IZkQ3K5JqXALr1hF6',
+ resultRev: 'q1XbfY2Gp42YDTcSgFsAQH',
+ timestamp: '2024-08-14T18:57:34.070170278Z',
+ visibility: 'transaction',
+ effects: {
+ apply: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '34',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#f00',
+ 'mesh',
+ 15,
+ ],
+ revert: [
+ 11,
+ 3,
+ 23,
+ 0,
+ 17,
+ 22,
+ '29',
+ 23,
+ 19,
+ 20,
+ 15,
+ 10,
+ 4,
+ 17,
+ '#ffff00',
+ 'mesh',
+ 15,
+ ],
+ },
+ transactionCurrentEvent: 1,
+ transactionTotalEvents: 1,
+ },
+] as const
diff --git a/src/machine/documentMutatorMachine.ts b/src/machine/documentMutatorMachine.ts
new file mode 100644
index 0000000..d398f3c
--- /dev/null
+++ b/src/machine/documentMutatorMachine.ts
@@ -0,0 +1,625 @@
+import {
+ type MutationEvent,
+ type SanityClient,
+ type SanityDocument,
+} from '@sanity/client'
+import {applyPatch, type RawPatch} from 'mendoza'
+import {asapScheduler, defer, filter, observeOn} from 'rxjs'
+import {
+ assertEvent,
+ assign,
+ enqueueActions,
+ fromEventObservable,
+ fromPromise,
+ raise,
+ sendParent,
+ setup,
+ spawnChild,
+ stopChild,
+} from 'xstate'
+
+import {encodeTransaction, type Mutation} from '../encoders/sanity'
+import {
+ type MutationGroup,
+ type SanityDocumentBase,
+ type Transaction,
+} from '../store'
+import {applyMutations} from '../store/documentMap/applyMutations'
+import {commit} from '../store/documentMap/commit'
+import {squashDMPStrings} from '../store/optimizations/squashDMPStrings'
+import {squashMutationGroups} from '../store/optimizations/squashMutations'
+import {rebase} from '../store/rebase'
+import {toTransactions} from '../store/toTransactions'
+import {createSharedListener, type SharedListenerEvents} from './listener'
+
+export {createSharedListener}
+
+export interface DocumentMutatorMachineInput {
+ id: string
+ client: SanityClient
+ /** A shared listener can be provided, if not it'll be created using `client.listen()` */
+ sharedListener?: ReturnType
+ /* Preferrably a LRU cache map that is compatible with an ES6 Map, and have documents that allow unique ids to a particular dataset */
+ cache?: Map
+}
+
+export type DocumentMutatorMachineParentEvent =
+ | {type: 'sync'; id: string; document: SanityDocumentBase}
+ | {
+ type: 'mutation'
+ id: string
+ effects: {apply: RawPatch}
+ previousRev: string
+ resultRev: string
+ }
+ | {type: 'rebased.local'; id: string; document: SanityDocumentBase}
+ | {type: 'rebased.remote'; id: string; document: SanityDocumentBase}
+ | {type: 'pristine'; id: string}
+
+export const documentMutatorMachine = setup({
+ types: {} as {
+ children: {
+ getDocument: 'fetch remote snapshot'
+ submitTransactions: 'submit mutations as transactions'
+ }
+ tags: 'busy' | 'error' | 'ready'
+ context: {
+ client: SanityClient
+ /** A shared listener can be provided, if not it'll be created using `client.listen()` */
+ sharedListener?: ReturnType
+ /** The document id */
+ id: string
+ /* Preferrably a LRU cache map that is compatible with an ES6 Map, and have documents that allow unique ids to a particular dataset */
+ cache?: Map
+ /* The remote snapshot of what the document looks like in Content Lake, kept in sync by applying Mendoza patches in real time. undefined means it's unknown if it exists yet, null means its known that it doesn't exist. */
+ remote: SanityDocument | null | undefined
+ /* Local snapshot, that is rebased to the remote snapshot whenever that snapshot changes, and allows optimistic local mutations. undefined means it's unknown if the document exists in content lake yet, if both `remote` and `local` is `null` it means it's known that it doesn't exist. If `remote` is defined, and `local` is `null` it means it's optimistically deleted. If `remote` is `null` and `local` defined then it's optimistically created. */
+ local: SanityDocument | null | undefined
+ /* Remote mendoza mutation events, needs a better name to differentiate from optimistic mutations */
+ mutationEvents: MutationEvent[]
+ /* Track staged mutations that can be submitted */
+ stagedChanges: MutationGroup[]
+ /* Queue mutations mutations that should be staged after an ongoing submission settles */
+ stashedChanges: MutationGroup[]
+ /* Any kind of error object that the UI can parse and decide how to display/report */
+ error: unknown
+ /* Used for automatic retrying of loading the remote snapshot */
+ fetchRemoteSnapshotAttempts: number
+ /* Used for automatic retrying of submitting mutations to Content Lake as a transaction */
+ submitTransactionsAttempts: number
+ }
+ events:
+ | SharedListenerEvents
+ | {type: 'error'}
+ | {type: 'retry'}
+ | {type: 'connect'}
+ | {type: 'reconnect'}
+ | {type: 'welcome'}
+ | {type: 'mutate'; mutations: Mutation[]}
+ | {type: 'submit'}
+ | {
+ type: 'xstate.done.actor.getDocument'
+ output: SanityDocument
+ }
+ | {
+ type: 'xstate.done.actor.submitTransactions'
+ output: undefined
+ }
+ input: DocumentMutatorMachineInput
+ },
+ actions: {
+ 'assign error to context': assign({error: ({event}) => event}),
+ 'clear error from context': assign({error: undefined}),
+ 'connect to server-sent events': raise({type: 'connect'}),
+ 'listen to server-sent events': spawnChild('server-sent events', {
+ id: 'listener',
+ input: ({context}) => ({
+ listener:
+ context.sharedListener || createSharedListener(context.client),
+ id: context.id,
+ }),
+ }),
+ 'stop listening to server-sent events': stopChild('listener'),
+ 'buffer remote mutation events': assign({
+ mutationEvents: ({event, context}) => {
+ assertEvent(event, 'mutation')
+ return [...context.mutationEvents, event]
+ },
+ }),
+ 'restore stashed changes': assign({
+ stagedChanges: ({event, context}) => {
+ assertEvent(event, 'xstate.done.actor.submitTransactions')
+ return context.stashedChanges
+ },
+ stashedChanges: [],
+ }),
+ 'rebase fetched remote snapshot': enqueueActions(({enqueue}) => {
+ enqueue.assign(({event, context}) => {
+ assertEvent(event, 'xstate.done.actor.getDocument')
+ const previousRemote = context.remote
+ let nextRemote = event.output
+
+ /**
+ * We assume all patches that happen while we're waiting for the document to resolve are already applied.
+ * But if we do see a patch that has the same revision as the document we just fetched, we should apply any patches following it
+ */
+ let seenCurrentRev = false
+ for (const patch of context.mutationEvents) {
+ if (
+ !patch.effects?.apply ||
+ (!patch.previousRev && patch.transition !== 'appear')
+ )
+ continue
+ if (!seenCurrentRev && patch.previousRev === nextRemote?._rev) {
+ seenCurrentRev = true
+ }
+ if (seenCurrentRev) {
+ nextRemote = applyMendozaPatch(
+ nextRemote,
+ patch.effects.apply,
+ patch.resultRev,
+ )
+ }
+ }
+
+ if (
+ context.cache &&
+ // If the shared cache don't have the document already we can just set it
+ (!context.cache.has(context.id) ||
+ // But when it's in the cache, make sure it's necessary to update it
+ context.cache.get(context.id)!._rev !== nextRemote?._rev)
+ ) {
+ context.cache.set(context.id, nextRemote as unknown as any)
+ }
+
+ const [stagedChanges, local] = rebase(
+ context.id,
+ // It's annoying to convert between null and undefined, reach consensus
+ previousRemote === null ? undefined : previousRemote,
+ nextRemote === null ? undefined : (nextRemote as unknown as any),
+ context.stagedChanges,
+ )
+
+ return {
+ remote: nextRemote as unknown as any,
+ local: local as unknown as any,
+ stagedChanges,
+ // Since the snapshot handler applies all the patches they are no longer needed, allow GC
+ mutationEvents: [],
+ }
+ })
+ enqueue.sendParent(
+ ({context}) =>
+ ({
+ type: 'rebased.remote',
+ id: context.id,
+ document: context.remote!,
+ }) satisfies DocumentMutatorMachineParentEvent,
+ )
+ }),
+ 'apply mendoza patch': assign(({event, context}) => {
+ assertEvent(event, 'mutation')
+ const previousRemote = context.remote
+ // We have already seen this mutation
+ if (event.transactionId === previousRemote?._rev) {
+ return {}
+ }
+
+ const nextRemote = applyMendozaPatch(
+ previousRemote!,
+ event.effects!.apply,
+ event.resultRev,
+ )
+
+ if (
+ context.cache &&
+ // If the shared cache don't have the document already we can just set it
+ (!context.cache.has(context.id) ||
+ // But when it's in the cache, make sure it's necessary to update it
+ context.cache.get(context.id)!._rev !== nextRemote?._rev)
+ ) {
+ context.cache.set(context.id, nextRemote as unknown as any)
+ }
+
+ const [stagedChanges, local] = rebase(
+ context.id,
+ // It's annoying to convert between null and undefined, reach consensus
+ previousRemote === null ? undefined : previousRemote,
+ nextRemote === null ? undefined : (nextRemote as unknown as any),
+ context.stagedChanges,
+ )
+
+ return {
+ remote: nextRemote as unknown as any,
+ local: local as unknown as any,
+ stagedChanges,
+ }
+ }),
+ 'increment fetch attempts': assign({
+ fetchRemoteSnapshotAttempts: ({context}) =>
+ context.fetchRemoteSnapshotAttempts + 1,
+ }),
+ 'reset fetch attempts': assign({
+ fetchRemoteSnapshotAttempts: 0,
+ }),
+ 'increment submit attempts': assign({
+ submitTransactionsAttempts: ({context}) =>
+ context.submitTransactionsAttempts + 1,
+ }),
+ 'reset submit attempts': assign({
+ submitTransactionsAttempts: 0,
+ }),
+ 'stage mutation': assign({
+ stagedChanges: ({event, context}) => {
+ assertEvent(event, 'mutate')
+ return [
+ ...context.stagedChanges,
+ {transaction: false, mutations: event.mutations},
+ ]
+ },
+ }),
+ 'stash mutation': assign({
+ stashedChanges: ({event, context}) => {
+ assertEvent(event, 'mutate')
+ return [
+ ...context.stashedChanges,
+ {transaction: false, mutations: event.mutations},
+ ]
+ },
+ }),
+ 'rebase local snapshot': enqueueActions(({enqueue}) => {
+ enqueue.assign({
+ local: ({event, context}) => {
+ assertEvent(event, 'mutate')
+ // @TODO would be helpful to not have to convert back and forth between maps
+ const localDataset = new Map()
+ if (context.local) {
+ localDataset.set(context.id, context.local)
+ }
+ // Apply mutations to local dataset (note: this is immutable, and doesn't change the dataset)
+ const results = applyMutations(event.mutations, localDataset)
+ // Write the updated results back to the "local" dataset
+ commit(results, localDataset)
+ // Read the result from the local dataset again
+ return localDataset.get(context.id)
+ },
+ })
+ enqueue.sendParent(
+ ({context}) =>
+ ({
+ type: 'rebased.local',
+ id: context.id,
+ document: context.local!,
+ }) satisfies DocumentMutatorMachineParentEvent,
+ )
+ }),
+ 'send pristine event to parent': sendParent(
+ ({context}) =>
+ ({
+ type: 'pristine',
+ id: context.id,
+ }) satisfies DocumentMutatorMachineParentEvent,
+ ),
+ 'send sync event to parent': sendParent(
+ ({context}) =>
+ ({
+ type: 'sync',
+ id: context.id,
+ document: context.remote!,
+ }) satisfies DocumentMutatorMachineParentEvent,
+ ),
+ 'send mutation event to parent': sendParent(({context, event}) => {
+ assertEvent(event, 'mutation')
+ return {
+ type: 'mutation',
+ id: context.id,
+ previousRev: event.previousRev!,
+ resultRev: event.resultRev!,
+ effects: event.effects!,
+ } satisfies DocumentMutatorMachineParentEvent
+ }),
+ },
+ actors: {
+ 'server-sent events': fromEventObservable(
+ ({
+ input,
+ }: {
+ input: {listener: ReturnType; id: string}
+ }) => {
+ const {listener, id} = input
+ return defer(() => listener).pipe(
+ filter(
+ event =>
+ event.type === 'welcome' ||
+ event.type === 'reconnect' ||
+ (event.type === 'mutation' && event.documentId === id),
+ ),
+ // This is necessary to avoid sync emitted events from `shareReplay` from happening before the actor is ready to receive them
+ observeOn(asapScheduler),
+ )
+ },
+ ),
+ 'fetch remote snapshot': fromPromise(
+ async ({
+ input,
+ signal,
+ }: {
+ input: {client: SanityClient; id: string}
+ signal: AbortSignal
+ }) => {
+ const {client, id} = input
+ const document = await client
+ .getDocument(id, {
+ signal,
+ })
+ .catch(e => {
+ if (e instanceof Error && e.name === 'AbortError') return
+ throw e
+ })
+
+ return document
+ },
+ ),
+ 'submit mutations as transactions': fromPromise(
+ async ({
+ input,
+ signal,
+ }: {
+ input: {client: SanityClient; transactions: Transaction[]}
+ signal: AbortSignal
+ }) => {
+ const {client, transactions} = input
+ for (const transaction of transactions) {
+ if (signal.aborted) return
+ await client
+ .dataRequest('mutate', encodeTransaction(transaction), {
+ visibility: 'async',
+ returnDocuments: false,
+ signal,
+ })
+ .catch(e => {
+ if (e instanceof Error && e.name === 'AbortError') return
+ throw e
+ })
+ }
+ },
+ ),
+ },
+ delays: {
+ // Exponential backoff delay function
+ fetchRemoteSnapshotTimeout: ({context}) =>
+ Math.pow(2, context.fetchRemoteSnapshotAttempts) * 1000,
+ submitTransactionsTimeout: ({context}) =>
+ Math.pow(2, context.submitTransactionsAttempts) * 1000,
+ },
+}).createMachine({
+ /** @xstate-layout N4IgpgJg5mDOIC5QQPYGMCuBbMA7ALgLRYb4CG+KATgMQnn5gDaADALqKgAOKsAlvj4pcnEAA9EADhYAWAHQA2GSwUBmAKwBOaQEZJkhQBoQAT0Q6ATAF8rx1JhwFipCtTkQ+sNMNxg0jCBpvXF9-Vg4kEB5+QWFRCQQLFhY5PQB2dRU1SQsdGSVjMwRlHTkWNM00nR1VKryKmzt0bDwielcqOWDQwVwoGgB3MAAbbxxw0WiBIRFIhPUKuQ1NVU0tTU0WFdVCxAt1dTlNhX2WdRltGUk061sQexandspO7r9e-qo-H3eJyKnYrNQPNJJollpVutNttdghVCo5MoTgoMpo8jIkqpGvdmo42i4Xl0fv4+H0aGAqFRqH9uLxpnE5ogFrCLFd5CtNBYFJkdOpuaosXcHnjnAw3G9-AAxMh8YYYL5BYn4GlROmA+KIFEs1R6ORpZYWHIXGR6bHC1qijpyL4Sj6DEZjZjsSZqmYaxK1ORcvlc-ZqSoyNKwnRpGTyK4WDK5BQ6BQ5BRm3EW55uG1K0n9ClUqgqgFuxketJe7nIv2rUNB0x7LmSL2qGMsSySevcmSJhzJgnipWQOgEma510M4HmcqHZYyWqaOMqTQyWGh0osVSGiwC5uGyftx74sWvHuBNMhX7O-5DoHiPae72lvnlwPBydFlRVS7qPIl7cilP74-+SByMMKBkB4ZKoL4cikgAbigADWYByDA+AACJJgQg4xPmI7FHkZQrKGsjqKcCiaMG9YpJOxySBiCiyPoX6dnuRJ-gEgHAaBmaUm4XDDBQABm1BYIhYAoWhyqnrSmHDpeCAKCicjUbU6g6nkFichYsJrLWVxaPsk7KdICZCmJlqEraAFASBvbPAOEmqlJF4JJURbVDcy4Yvk1GVkUsabApMjKZGqjNg2kgMU8Xa-j0FnsQBXBUJ4vRgH2DBOhEkn0o5TLKV6y6WDG6lhkYVYIDoKzyBkeR8pUDZqOFu5WuZEBsVZzUeFQ+AmDQsAYAARlgAgYZl7o6I2tYLJINSSO+3JaMGlQpGkhFhryo2jW2xkdhFTFNS1EAAT1-UCHa4EIdBcEIYdA34AAKlQZC4LAZAksIsBDeqBbrdpym8iuByxlcLK5BYqQLBUZyTcFvL1aZ3YsTFrVyFdx0ZuSXGdDx-GCUjfXXXdD1PS9j3vVhMnrWCFRxtNaRLdcfIsiwuS5fkgZaOUlhpDDP7MdFzWWftzXI-gdrPGlLoOSNyiHFstRySisjcgzahyFoUYBbRsac5tO6w1F7wIwLONHfg0qyvKyVfPgVAmCT0kJGt41pJD02xgcpElUkcZ6rI+q5JcckbU0W0NWZB57QduMCKbcoKmIsCpXIZB8YwVAABRC-jj3PYCsA3XwOAoKQACUNDmttjVh-zEfG9H5u21lpVjSrTtTTNbsstUYJJAFWgA37XORTz+t8+xtcKpb1v1+6KJFopGQqRi6nBlstYcqNK5riusYDztlejzKMfJXHCdJynqd8SJaAABYAEpgFgKCMAAyrgZBcLAV+P3nBfF6XJnc7tfmY8xZnglgWGe-klILzUhYDSJVRpnCONNOcWxWRKBRDYO4uAUD7XgJEMuIdqDi2GgWQgOhgxMyRKyFc8IuQkXUDvK0HgvAHmIR9bCD4SoeSWCoLkoZpxrmXIw0OLEMxsNJgkSc418KhnrHyG4oZYTcPhMifhJx4SCiDjrABSpgHiLtogAUhwW77DRIGXkk55wexKCrJQsg1jBTnPsYRqZviiL6PohuORgyhhBhGKMsZYzxhcXrf8EBPHulUJOPCtRlABWIu7IoORVBIIhMpNYGJyghKHmEvaYjQEkOwhkxQrJtBES0JDOBPlkgKD1JYZSjZORyTXNkwBsVwkFPYTJVYS58JxPKbOYM0gUj7FjGcEMOpciaJxMHXWOTWJV2avFRKpIwARILJRGJBF4mZBIkDE0KtIxLSSDkDYGhWl70Ru1Tq6zsLURBkoLyWRmz8PmlUZmcY1zXDKdMghcy2mIyFh8W5ZN4RFmOFyKaZwiLeT2GVJcy5pBrguCiMK2tvyDwBYbIWejOkSMQBkeQORJq0RNCUDIDNondxuGpPQmRqIXPhiPECuKMpdMkbILZ-SEnLxyjqOJMZsj1jRTYIAA */
+
+ id: 'document-mutator',
+
+ context: ({input}) => ({
+ client: input.client.withConfig({allowReconfigure: false}),
+ sharedListener: input.sharedListener,
+ id: input.id,
+ remote: undefined,
+ local: undefined,
+ mutationEvents: [],
+ stagedChanges: [],
+ stashedChanges: [],
+ error: undefined,
+ fetchRemoteSnapshotAttempts: 0,
+ submitTransactionsAttempts: 0,
+ cache: input.cache,
+ }),
+
+ // Auto start the connection by default
+ entry: ['connect to server-sent events'],
+
+ on: {
+ mutate: {
+ actions: ['rebase local snapshot', 'stage mutation'],
+ },
+ },
+ initial: 'disconnected',
+ states: {
+ disconnected: {
+ on: {
+ connect: {
+ target: 'connecting',
+ actions: ['listen to server-sent events'],
+ },
+ },
+ },
+ connecting: {
+ on: {
+ welcome: 'connected',
+ reconnect: 'reconnecting',
+ error: 'connectFailure',
+ },
+ tags: ['busy'],
+ },
+ connectFailure: {
+ on: {
+ connect: {
+ target: 'connecting',
+ actions: ['listen to server-sent events'],
+ },
+ },
+ entry: [
+ 'stop listening to server-sent events',
+ 'assign error to context',
+ ],
+ exit: ['clear error from context'],
+ tags: ['error'],
+ },
+ reconnecting: {
+ on: {
+ welcome: {
+ target: 'connected',
+ },
+ error: {
+ target: 'connectFailure',
+ },
+ },
+ tags: ['busy', 'error'],
+ },
+ connected: {
+ on: {
+ mutation: {
+ actions: ['buffer remote mutation events'],
+ },
+ reconnect: 'reconnecting',
+ },
+ entry: ['clear error from context'],
+ initial: 'loading',
+ states: {
+ loading: {
+ invoke: {
+ src: 'fetch remote snapshot',
+ id: 'getDocument',
+ input: ({context}) => ({
+ client: context.client,
+ id: context.id,
+ }),
+ onError: {
+ target: 'loadFailure',
+ },
+ onDone: {
+ target: 'loaded',
+ actions: [
+ 'rebase fetched remote snapshot',
+ 'reset fetch attempts',
+ ],
+ },
+ },
+
+ tags: ['busy'],
+ },
+
+ loaded: {
+ entry: ['send sync event to parent'],
+ on: {
+ mutation: {
+ actions: ['apply mendoza patch', 'send mutation event to parent'],
+ },
+ },
+ initial: 'pristine',
+
+ states: {
+ pristine: {
+ on: {
+ mutate: {
+ actions: ['rebase local snapshot', 'stage mutation'],
+ target: 'dirty',
+ },
+ },
+ tags: ['ready'],
+ },
+ dirty: {
+ on: {
+ submit: 'submitting',
+ },
+ tags: ['ready'],
+ },
+ submitting: {
+ on: {
+ mutate: {
+ actions: ['rebase local snapshot', 'stash mutation'],
+ },
+ },
+ invoke: {
+ src: 'submit mutations as transactions',
+ id: 'submitTransactions',
+ input: ({context}) => {
+ // @TODO perhaps separate utils to be lower level and operate on single documents at a time instead of expecting a local dataset
+ const remoteDataset = new Map()
+ remoteDataset.set(context.id, context.remote)
+ return {
+ client: context.client,
+ transactions: toTransactions(
+ // Squashing DMP strings is the last thing we do before submitting
+ squashDMPStrings(
+ remoteDataset,
+ squashMutationGroups(context.stagedChanges),
+ ),
+ ),
+ }
+ },
+ onError: {
+ target: 'submitFailure',
+ },
+
+ onDone: {
+ target: 'pristine',
+ actions: [
+ 'restore stashed changes',
+ 'reset submit attempts',
+ 'send pristine event to parent',
+ ],
+ },
+ },
+ /**
+ * 'busy' means we should show a spinner, 'ready' means we can still accept mutations, they'll be applied optimistically right away, and queued for submissions after the current submission settles
+ */
+ tags: ['busy', 'ready'],
+ },
+ submitFailure: {
+ exit: ['clear error from context'],
+ after: {
+ submitTransactionsTimeout: {
+ actions: ['increment submit attempts'],
+ target: 'submitting',
+ },
+ },
+ on: {
+ retry: 'submitting',
+ },
+ /**
+ * How can it be both `ready` and `error`? `ready` means it can receive mutations, optimistically apply them, and queue them for submission. `error` means it failed to submit previously applied mutations.
+ * It's completely fine to keep queueing up more mutations and applying them optimistically, while showing UI that notifies that mutations didn't submit, and show a count down until the next automatic retry.
+ */
+ tags: ['error', 'ready'],
+ },
+ },
+ },
+
+ loadFailure: {
+ exit: ['clear error from context'],
+ after: {
+ fetchRemoteSnapshotTimeout: {
+ actions: ['increment fetch attempts'],
+ target: 'loading',
+ },
+ },
+ on: {
+ retry: 'loading',
+ },
+ tags: ['error'],
+ },
+ },
+ },
+ },
+})
+
+function applyMendozaPatch(
+ document: DocumentType | undefined,
+ patch: RawPatch,
+ nextRevision: string | undefined,
+) {
+ const next = applyPatch(omitRev(document), patch)
+ if (!next) {
+ return null
+ }
+ return Object.assign(next, {_rev: nextRevision})
+}
+
+function omitRev(
+ document: DocumentType | undefined,
+) {
+ if (!document) {
+ return null
+ }
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ const {_rev, ...doc} = document
+ return doc
+}
diff --git a/src/machine/index.ts b/src/machine/index.ts
new file mode 100644
index 0000000..6af5c67
--- /dev/null
+++ b/src/machine/index.ts
@@ -0,0 +1,36 @@
+export {type UpdateResult} from '../store/documentMap/applyMutations'
+export {type DataStore} from '../store/optimizations/squashDMPStrings'
+export type {
+ Conflict,
+ DocumentMap,
+ LocalDataset,
+ MutationGroup,
+ MutationResult,
+ NonTransactionalMutationGroup,
+ OptimisticDocumentEvent,
+ RawOperation,
+ RawPatch,
+ RemoteDocumentEvent,
+ RemoteMutationEvent,
+ RemoteSyncEvent,
+ SubmitResult,
+ TransactionalMutationGroup,
+} from '../store/types'
+export * from './documentMutatorMachine'
+
+/** Required support types */
+export type * from '../mutations/operations/types'
+export type {NodePatch, PatchOptions} from '../mutations/types'
+export type {
+ CreateIfNotExistsMutation,
+ CreateMutation,
+ CreateOrReplaceMutation,
+ DeleteMutation,
+ Mutation,
+ NodePatchList,
+ PatchMutation,
+ SanityDocumentBase,
+ Transaction,
+} from '../mutations/types'
+export type * from '../path'
+export type {Optional} from '../utils/typeUtils'
diff --git a/src/machine/listener.ts b/src/machine/listener.ts
new file mode 100644
index 0000000..5d17524
--- /dev/null
+++ b/src/machine/listener.ts
@@ -0,0 +1,60 @@
+import {
+ type MutationEvent,
+ type ReconnectEvent,
+ type SanityClient,
+ type WelcomeEvent,
+} from '@sanity/client'
+import {filter, merge, type ObservedValueOf, share, shareReplay} from 'rxjs'
+
+/**
+ * Creates a single, shared, listener EventSource that strems remote mutations, and notifies when it's online (welcome), offline (reconnect).
+ */
+export function createSharedListener(client: SanityClient) {
+ const allEvents$ = client
+ .listen(
+ '*[!(_id in path("_.**"))]',
+ {},
+ {
+ events: ['welcome', 'mutation', 'reconnect'],
+ includeResult: false,
+ includePreviousRevision: false,
+ visibility: 'transaction',
+ effectFormat: 'mendoza',
+ includeMutations: false,
+ },
+ )
+ .pipe(share({resetOnRefCountZero: true}))
+
+ // Reconnect events emitted in case the connection is lost
+ const reconnect = allEvents$.pipe(
+ filter((event): event is ReconnectEvent => event.type === 'reconnect'),
+ )
+
+ // Welcome events are emitted when the listener is (re)connected
+ const welcome = allEvents$.pipe(
+ filter((event): event is WelcomeEvent => event.type === 'welcome'),
+ )
+
+ // Mutation events coming from the listener
+ const mutations = allEvents$.pipe(
+ filter((event): event is MutationEvent => event.type === 'mutation'),
+ )
+
+ // Replay the latest connection event that was emitted either when the connection was disconnected ('reconnect'), established or re-established ('welcome')
+ const connectionEvent = merge(welcome, reconnect).pipe(
+ shareReplay({bufferSize: 1, refCount: true}),
+ )
+
+ // Emit the welcome event if the latest connection event was the 'welcome' event.
+ // Downstream subscribers will typically map the welcome event to an initial fetch
+ const replayWelcome = connectionEvent.pipe(
+ filter(latestConnectionEvent => latestConnectionEvent.type === 'welcome'),
+ )
+
+ // Combine into a single stream
+ return merge(replayWelcome, mutations, reconnect)
+}
+
+export type SharedListenerEvents = ObservedValueOf<
+ ReturnType
+>
diff --git a/src/store/__test__/squashMutations.test.ts b/src/store/__test__/squashMutations.test.ts
index 536cb80..0183e35 100644
--- a/src/store/__test__/squashMutations.test.ts
+++ b/src/store/__test__/squashMutations.test.ts
@@ -23,7 +23,7 @@ test('squashMutations() merges subsequent patch mutations for the same document'
])
})
-test('squashMutations() merges subsequent patch mutations for the same document', () => {
+test('squashMutations() merges subsequent patch mutations for different documents', () => {
const mutations = [
patch('doc1', [at('foo', set('a'))]),
patch('doc2', [at('bar', set('xyz'))]),
diff --git a/src/store/createOptimisticStore.ts b/src/store/createOptimisticStore.ts
index be9f2f2..aa1e7cb 100644
--- a/src/store/createOptimisticStore.ts
+++ b/src/store/createOptimisticStore.ts
@@ -23,6 +23,7 @@ import {createDocumentMap} from './documentMap/createDocumentMap'
import {squashDMPStrings} from './optimizations/squashDMPStrings'
import {squashMutationGroups} from './optimizations/squashMutations'
import {rebase} from './rebase'
+import {toTransactions} from './toTransactions'
import {
type ListenerEvent,
type LocalDataset,
@@ -265,12 +266,3 @@ export function createOptimisticStore(
},
}
}
-
-function toTransactions(groups: MutationGroup[]): Transaction[] {
- return groups.map(group => {
- if (group.transaction && group.id !== undefined) {
- return {id: group.id!, mutations: group.mutations}
- }
- return {mutations: group.mutations}
- })
-}
diff --git a/src/store/mendozaTypes.ts b/src/store/mendozaTypes.ts
new file mode 100644
index 0000000..c1a7900
--- /dev/null
+++ b/src/store/mendozaTypes.ts
@@ -0,0 +1 @@
+export type {RawOperation, RawPatch} from 'mendoza'
diff --git a/src/store/optimizations/squashDMPStrings.ts b/src/store/optimizations/squashDMPStrings.ts
index ca7a9bd..3cab7e6 100644
--- a/src/store/optimizations/squashDMPStrings.ts
+++ b/src/store/optimizations/squashDMPStrings.ts
@@ -7,7 +7,7 @@ import {
import {type MutationGroup} from '../types'
import {compactDMPSetPatches} from './squashNodePatches'
-interface DataStore {
+export interface DataStore {
get: (id: string) => SanityDocumentBase | undefined
}
export function squashDMPStrings(
diff --git a/src/store/toTransactions.ts b/src/store/toTransactions.ts
new file mode 100644
index 0000000..eb14e4a
--- /dev/null
+++ b/src/store/toTransactions.ts
@@ -0,0 +1,11 @@
+import {type Transaction} from '../mutations/types'
+import {type MutationGroup} from './types'
+
+export function toTransactions(groups: MutationGroup[]): Transaction[] {
+ return groups.map(group => {
+ if (group.transaction && group.id !== undefined) {
+ return {id: group.id!, mutations: group.mutations}
+ }
+ return {mutations: group.mutations}
+ })
+}
diff --git a/src/store/types.ts b/src/store/types.ts
index 4c4df94..93a65fd 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -1,10 +1,11 @@
-import {type RawPatch} from 'mendoza'
+import {type RawOperation, type RawPatch} from 'mendoza'
import {type Observable} from 'rxjs'
import {type Mutation, type SanityDocumentBase} from '../mutations/types'
import {type Path} from '../path'
import {type SanityMutation} from './sanityMutationTypes'
+export {type RawOperation, type RawPatch}
export interface ListenerSyncEvent<
Doc extends SanityDocumentBase = SanityDocumentBase,
> {
diff --git a/src/utils/typeUtils.ts b/src/utils/typeUtils.ts
index 41981fe..22d8c1e 100644
--- a/src/utils/typeUtils.ts
+++ b/src/utils/typeUtils.ts
@@ -19,7 +19,7 @@ export type ArrayLength = T extends never[]
export type Optional = Omit & Partial>
/**
- * Formats an intersection object type, so it outputs as `{"foo": 1, "bar": 1}` instead of `{"foo": 1} & {"bar": 2}``
+ * Formats an intersection object type, so it outputs as `{"foo": 1, "bar": 1}` instead of `{"foo": 1} & {"bar": 2}`
*/
export type Format = A extends {[Key in keyof A]: A[Key]}
? {[Key in keyof A]: A[Key]}
diff --git a/tsconfig.settings.json b/tsconfig.settings.json
index 9770e7c..06ab906 100644
--- a/tsconfig.settings.json
+++ b/tsconfig.settings.json
@@ -1,8 +1,8 @@
{
"compilerOptions": {
- "module": "ES2022",
- "target": "ES2022",
- "lib": ["es2023"],
+ "module": "ESNext",
+ "target": "ESNext",
+ "lib": ["es2023", "esnext.promise"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,