Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c37e873
chore!: upgrade immer to version 10
johannes-lindgren Jul 20, 2024
4d8f467
test: example app
johannes-lindgren Jan 23, 2025
ef4ae88
Merge branch 'main' into chore/immer-v10
johannes-lindgren Jan 23, 2025
80f759b
Merge branch 'chore/immer-v10' into tests/example-app
johannes-lindgren Jan 23, 2025
80025c0
chore: format code
johannes-lindgren Jan 23, 2025
7c7fdb4
feat: various changes
johannes-lindgren Jan 23, 2025
fe7a08c
feat: various changes
johannes-lindgren Jan 23, 2025
2507c5b
ci: test
johannes-lindgren Jan 23, 2025
427e574
fix: ts
johannes-lindgren Jan 23, 2025
2f02d25
fix: ts
johannes-lindgren Jan 23, 2025
b1b92a9
fix: ts output
johannes-lindgren Jan 23, 2025
7ba0435
chore: format
johannes-lindgren Jan 23, 2025
99f60da
feat: demo with context
johannes-lindgren Jan 23, 2025
3ffec9e
feat: fix
johannes-lindgren Jan 23, 2025
33300ed
feat: improved example
johannes-lindgren Jan 24, 2025
9399c25
Merge branch 'main' into tests/example-app
johannes-lindgren Jan 25, 2025
ff26850
chore: fix ts error
johannes-lindgren Jan 25, 2025
ceb5f57
Merge branch 'main' into tests/example-app
johannes-lindgren Jan 25, 2025
7df5090
chore: format
johannes-lindgren Jan 25, 2025
5c7b847
refactor: no stack
johannes-lindgren Jan 25, 2025
7576643
style: stack stretch
johannes-lindgren Jan 27, 2025
5818d08
old work
johannes-lindgren Dec 1, 2025
4af98f7
Merge branch 'main' into tests/example-app
johannes-lindgren Sep 10, 2026
47033d8
refactor: rename example app files and update package.json
johannes-lindgren Sep 10, 2026
9c72835
feat: enhance immer-yjs React example with improved UI and performanc…
johannes-lindgren Sep 10, 2026
3d52c8f
feat: enhance JSON view with improved rendering and styling
johannes-lindgren Sep 10, 2026
a3d57d0
feat: enhance JSON view with improved rendering and styling
johannes-lindgren Sep 10, 2026
fa4cdb2
docs: add example app link to README
johannes-lindgren Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/code-integration-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ jobs:
- name: Build
if: always()
run: yarn build
# TODO test build output: https://github.com/sep2/immer-yjs/issues/18
# - name: Test Build Output
# if: always()
# run: yarn workspace test-app test
- name: Test Build Output
if: always()
run: yarn workspace immer-yjs-react-example build
24 changes: 24 additions & 0 deletions apps/immer-yjs-react/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
53 changes: 53 additions & 0 deletions apps/immer-yjs-react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# immer-yjs React example

A minimal React app demonstrating [`immer-yjs`](../../packages/immer-yjs): a `Y.Doc` is shared between
browser peers over WebRTC, and components read and write it with `immer`-style mutations instead of
hand-written patches.

## Running

From the repository root:

```sh
yarn workspace immer-yjs-react-example dev
```

Open the printed URL in two windows to watch edits propagate between them.

## How it fits together

| File | Role |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `immer-yjs-react.ts` | The React bindings: `useImmerYjs` creates and binds the document, `useSelection` subscribes to it, `useBinder` writes to it. |
| `DemoApp.tsx` | Attaches a `WebrtcProvider` and renders a counter and a text field backed by the shared document. |
| `AppState.ts` | The shape of the document, plus a [`pure-parse`](https://github.com/johannes-lindgren/pure-parse) type guard for it. |
| `JsonView.tsx` | Renders the document as a tree of memoized nodes, one component per value. |

## Watching re-renders

`App.tsx` drives [react-scan](https://github.com/aidenybai/react-scan) through its `useScan` hook, wired
to a "Highlight re-renders" switch that defaults to off. `main.tsx` imports the package for its side
effect only, and keeps that import above the React import: react-scan installs the React DevTools hook
when it loads, and can only instrument React if it runs first.

Switch it on and type in the text field. immer-yjs snapshots are immutable and structurally shared—an
update rebuilds only the path from the root down to the value that changed—so `memo` skips every
untouched subtree. `JsonView.tsx` leans on this by rendering one memoized component per JSON value
rather than stringifying the document: a single keystroke re-renders the `"text"` row and leaves the
`"count"` row untouched. Stringifying instead would rebuild every line on every keystroke.

Two react-scan quirks worth knowing: `useScan` is called without `trackUnnecessaryRenders`, which
appears in its type definitions but is rejected by its runtime validator in 0.5.7; and switching the
scan off leaves any already-drawn outlines on screen until the next reload, since react-scan does not
clear its overlay when paused.

## Validating the document

A `Y.Doc` carries no schema, and this one is shared with untrusted peers, so a synced document may hold
anything. Every selector and every update in `DemoApp.tsx` therefore narrows the snapshot with the
`isAppState` guard before reading a field.

Note that this validates with a **type guard** rather than a parser. `useSelection` is built on
`useSyncExternalStore`, which compares selector results with `Object.is`; a parser allocates a fresh
result object on every call, so using one as a selector would re-render forever. A guard returns the
same reference, narrowed, and is therefore stable.
25 changes: 25 additions & 0 deletions apps/immer-yjs-react/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'

export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
}
)
12 changes: 12 additions & 0 deletions apps/immer-yjs-react/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>immer-yjs React Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
36 changes: 36 additions & 0 deletions apps/immer-yjs-react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "immer-yjs-react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"check:types": "tsc -b --noEmit"
},
"dependencies": {
"immer": "^11.1.18",
"immer-yjs": "workspace:*",
"pure-parse": "^1.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scan": "^0.5.7",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.23"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vite": "^6.0.5"
}
}
142 changes: 142 additions & 0 deletions apps/immer-yjs-react/src/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#root {
max-width: 52rem;
margin: 0 auto;
padding: 2rem;
text-align: center;

display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.stack {
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
gap: 10px;
}

/* Let nested stacks span the text column, so the JSON view below can fill it. */
.stack > .stack {
align-self: stretch;
}

.stack > p {
align-self: stretch;
margin: 0;
text-align: left;
}

.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12px;
}

.toggle {
display: flex;
align-items: center;
gap: 0.5ch;
cursor: pointer;
user-select: none;
}

.count {
font-size: 1.25rem;
min-width: 3ch;
text-align: left;
}

input[type='text'] {
min-width: 18rem;
padding: 0.5em 0.75em;
border: 1px solid rgba(127, 127, 127, 0.4);
border-radius: 8px;
background-color: transparent;
color: inherit;
font-family: inherit;
font-size: 1rem;
}

.code-block {
box-sizing: border-box;
align-self: stretch;
margin: 0;
padding: 0.85rem 1.25rem;
border: 1px solid rgba(127, 127, 127, 0.4);
border-radius: 8px;
background-color: rgba(127, 127, 127, 0.12);
text-align: left;
font-size: 1rem;
overflow-x: auto;
}

.caption {
align-self: flex-start;
opacity: 0.65;
}

.json-view {
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 1rem 1.25rem;
border: 1px solid rgba(127, 127, 127, 0.4);
border-radius: 8px;
background-color: rgba(127, 127, 127, 0.12);
text-align: left;
/* No longer a <pre>, so the monospace font and significant whitespace
(spaces inside string values) have to be asked for explicitly. */
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 1rem;
line-height: 1.6;
white-space: pre-wrap;
overflow-x: auto;
}

/* One indentation step per level of nesting, in place of JSON.stringify's spaces. */
.json-indent {
padding-left: 2ch;
}

.json-punctuation {
opacity: 0.55;
}

.json-key {
color: #9cdcfe;
}

.json-string {
color: #ce9178;
}

.json-number {
color: #b5cea8;
}

.json-keyword {
color: #c586c0;
}

@media (prefers-color-scheme: light) {
.json-key {
color: #0451a5;
}

.json-string {
color: #a31515;
}

.json-number {
color: #098658;
}

.json-keyword {
color: #af00db;
}
}
60 changes: 60 additions & 0 deletions apps/immer-yjs-react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { useScan } from 'react-scan'
import { DemoApp } from './DemoApp'
import './App.css'

export const App = () => {
const [highlightRerenders, setHighlightRerenders] = useState(false)

// Driving react-scan from state, rather than calling scan() once at startup,
// is what lets it default to off and be toggled at any time.
useScan({
enabled: highlightRerenders,
showToolbar: highlightRerenders,
})

return (
<div className="stack">
<h2>immer-yjs Example</h2>
<p>
A simple example with <code>immer-yjs</code> and <code>Y.js</code> libraries working together with
React.
</p>
<p>
The counter and text field below live in a Y.js document that is shared between peers with the
<code>y-webrtc</code> provider.{' '}
<a href="/" target="_blank" rel="noopener noreferrer">
Open this page in a new window
</a>{' '}
to show how both copies converge.
</p>
<label className="toggle">
<input
type="checkbox"
checked={highlightRerenders}
onChange={(event) => setHighlightRerenders(event.target.checked)}
/>
Highlight re-renders
</label>
<DemoApp />
<h2>How it works</h2>
<p>
<code>immer-yjs</code> translates mutations into CRDT operations, and translates incoming remote
operations back into immutable snapshots that components subscribe to.
</p>
<p>Here is how to update the document in code:</p>
<pre className="code-block">
<code>{'binder.update((state) => state.count++)'}</code>
</pre>
<p>
Where <code>state</code> is an Immer document
</p>
<p>
Because each snapshot is immutable, a <code>memo</code>-wrapped component only re-renders when the value
it selected actually changed. Switch on <a href="https://github.com/aidenybai/react-scan">react-scan</a>{' '}
and type in the text field: it outlines the field and the one JSON row that changed, and leaves the
counter alone.
</p>
</div>
)
}
11 changes: 11 additions & 0 deletions apps/immer-yjs-react/src/AppState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { isNumber, isString, objectGuard } from 'pure-parse'

export type AppState = {
count: number
text: string
}

export const isAppState = objectGuard<AppState>({
count: isNumber,
text: isString,
})
Loading
Loading