@shapeshift-labs/frontier-react 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/benchmarks/e2e-flow.mjs +305 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -191,6 +191,7 @@ The package ships ESM JavaScript plus `.d.ts` declarations for root, `./store`,
|
|
|
191
191
|
npm test
|
|
192
192
|
npm run fuzz
|
|
193
193
|
npm run bench
|
|
194
|
+
npm run bench:e2e
|
|
194
195
|
npm run pack:dry
|
|
195
196
|
```
|
|
196
197
|
|
|
@@ -198,20 +199,28 @@ The package test suite covers root and subpath imports, patch-store commits, pat
|
|
|
198
199
|
|
|
199
200
|
## Benchmarks
|
|
200
201
|
|
|
201
|
-
Run the package-local
|
|
202
|
+
Run the package-local benchmarks:
|
|
202
203
|
|
|
203
204
|
```sh
|
|
204
205
|
npm run bench
|
|
206
|
+
npm run bench:e2e -- --out benchmarks/results/e2e-flow-latest.json
|
|
205
207
|
```
|
|
206
208
|
|
|
207
|
-
Latest local package benchmark on Node v26.1.0, darwin arm64,
|
|
209
|
+
Latest local package benchmark on Node v26.1.0, darwin arm64, 9 rounds:
|
|
208
210
|
|
|
209
211
|
| Fixture | Median | p95 |
|
|
210
212
|
| --- | ---: | ---: |
|
|
211
|
-
| React patch store replace, 1k rows one edit |
|
|
212
|
-
| External store adapter notify 10 listeners | 0.
|
|
213
|
+
| React patch store replace, 1k rows one edit | 228.82 us | 241.33 us |
|
|
214
|
+
| External store adapter notify 10 listeners | 0.08 us | 0.13 us |
|
|
213
215
|
| State engine adapter snapshot read | 0.01 us | 0.01 us |
|
|
214
216
|
|
|
217
|
+
Latest local full client/server React flow benchmark on Node v26.1.0, darwin arm64, 80 measured iterations after 12 warmup iterations:
|
|
218
|
+
|
|
219
|
+
| Fixture | Total median | Total p95 | Local commit | Provider sync call | Remote render wait | Update bytes |
|
|
220
|
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
221
|
+
| E2E CRDT text insert to React render | 2.78 ms | 8.77 ms | 29.08 us | 139.50 us | 2.51 ms | 41 |
|
|
222
|
+
| E2E CRDT JSON set to React render | 3.29 ms | 8.18 ms | 23.71 us | 190.79 us | 2.88 ms | 50 |
|
|
223
|
+
|
|
215
224
|
These are Frontier-only package measurements, not competitor comparisons.
|
|
216
225
|
|
|
217
226
|
## License
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import TestRenderer, { act } from 'react-test-renderer';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import { createCrdtStateEngine } from '@shapeshift-labs/frontier-crdt/state';
|
|
9
|
+
import { createCrdtSyncEndpoint } from '@shapeshift-labs/frontier-crdt-sync';
|
|
10
|
+
import { createCrdtWebSocketProvider } from '@shapeshift-labs/frontier-crdt-websocket';
|
|
11
|
+
import { createCrdtWebSocketServer } from '@shapeshift-labs/frontier-crdt-websocket/server';
|
|
12
|
+
import {
|
|
13
|
+
createFrontierCrdtStore,
|
|
14
|
+
useFrontierStore
|
|
15
|
+
} from '../dist/index.js';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const rootDir = path.resolve(__dirname, '..');
|
|
19
|
+
const args = parseArgs(process.argv.slice(2));
|
|
20
|
+
const iterations = readPositiveInt(args.iterations, 80);
|
|
21
|
+
const warmup = readPositiveInt(args.warmup, 12);
|
|
22
|
+
const outPath = args.out ? path.resolve(rootDir, args.out) : null;
|
|
23
|
+
|
|
24
|
+
const textRun = await runTextTypingScenario({ iterations, warmup });
|
|
25
|
+
const setRun = await runJsonSetScenario({ iterations, warmup });
|
|
26
|
+
|
|
27
|
+
finish('@shapeshift-labs/frontier-react', [
|
|
28
|
+
summarizeScenario('E2E CRDT text insert to React render', textRun),
|
|
29
|
+
summarizeScenario('E2E CRDT JSON set to React render', setRun)
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
async function runTextTypingScenario(options) {
|
|
33
|
+
return withTwoPeerReactFlow(async ({ alice, aliceProvider, readRendered, waitForRender }) => {
|
|
34
|
+
const samples = [];
|
|
35
|
+
let expected = '';
|
|
36
|
+
for (let i = 0; i < options.warmup + options.iterations; i++) {
|
|
37
|
+
const char = String.fromCharCode(97 + (i % 26));
|
|
38
|
+
const start = performance.now();
|
|
39
|
+
const commitStart = performance.now();
|
|
40
|
+
const commit = alice.text('/body').insert(expected.length, char);
|
|
41
|
+
expected += char;
|
|
42
|
+
const commitEnd = performance.now();
|
|
43
|
+
const syncStart = performance.now();
|
|
44
|
+
await aliceProvider.sync('bob');
|
|
45
|
+
const syncEnd = performance.now();
|
|
46
|
+
await waitForRender(expected);
|
|
47
|
+
const end = performance.now();
|
|
48
|
+
assertRendered(readRendered(), expected);
|
|
49
|
+
if (i >= options.warmup) {
|
|
50
|
+
samples.push({
|
|
51
|
+
totalUs: micros(end - start),
|
|
52
|
+
localCommitUs: micros(commitEnd - commitStart),
|
|
53
|
+
syncCallUs: micros(syncEnd - syncStart),
|
|
54
|
+
renderWaitUs: micros(end - syncEnd),
|
|
55
|
+
updateBytes: commit.update.byteLength,
|
|
56
|
+
patchOps: commit.viewPatch.length
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return samples;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runJsonSetScenario(options) {
|
|
65
|
+
return withTwoPeerReactFlow(async ({ alice, aliceProvider, readRendered, waitForRender }) => {
|
|
66
|
+
const samples = [];
|
|
67
|
+
for (let i = 0; i < options.warmup + options.iterations; i++) {
|
|
68
|
+
const expected = `title-${i}`;
|
|
69
|
+
const start = performance.now();
|
|
70
|
+
const commitStart = performance.now();
|
|
71
|
+
const commit = alice.set('/title', expected);
|
|
72
|
+
const commitEnd = performance.now();
|
|
73
|
+
const syncStart = performance.now();
|
|
74
|
+
await aliceProvider.sync('bob');
|
|
75
|
+
const syncEnd = performance.now();
|
|
76
|
+
await waitForRender(expected);
|
|
77
|
+
const end = performance.now();
|
|
78
|
+
assertRendered(readRendered(), expected);
|
|
79
|
+
if (i >= options.warmup) {
|
|
80
|
+
samples.push({
|
|
81
|
+
totalUs: micros(end - start),
|
|
82
|
+
localCommitUs: micros(commitEnd - commitStart),
|
|
83
|
+
syncCallUs: micros(syncEnd - syncStart),
|
|
84
|
+
renderWaitUs: micros(end - syncEnd),
|
|
85
|
+
updateBytes: commit.update.byteLength,
|
|
86
|
+
patchOps: commit.viewPatch.length
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return samples;
|
|
91
|
+
}, { selector: (state) => state?.title ?? '' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function withTwoPeerReactFlow(callback, options = {}) {
|
|
95
|
+
const server = createCrdtWebSocketServer({
|
|
96
|
+
host: '127.0.0.1',
|
|
97
|
+
port: 0,
|
|
98
|
+
heartbeatIntervalMs: 0,
|
|
99
|
+
frameEncoding: 'binary'
|
|
100
|
+
});
|
|
101
|
+
await server.ready;
|
|
102
|
+
const url = serverUrl(server);
|
|
103
|
+
const alice = createCrdtStateEngine({ actorId: `alice-${Date.now()}` });
|
|
104
|
+
const bob = createCrdtStateEngine({ actorId: `bob-${Date.now()}` });
|
|
105
|
+
const aliceProvider = createProvider(alice, 'alice', url);
|
|
106
|
+
const bobProvider = createProvider(bob, 'bob', url);
|
|
107
|
+
const bobStore = createFrontierCrdtStore(bob);
|
|
108
|
+
const selector = options.selector ?? ((state) => state?.body ?? '');
|
|
109
|
+
const renderState = {
|
|
110
|
+
value: selector(bobStore.getSnapshot()),
|
|
111
|
+
renders: 0
|
|
112
|
+
};
|
|
113
|
+
const renderWaiters = new Set();
|
|
114
|
+
|
|
115
|
+
function Probe() {
|
|
116
|
+
renderState.renders++;
|
|
117
|
+
renderState.value = useFrontierStore(bobStore, selector);
|
|
118
|
+
resolveRenderWaiters(renderState.value, renderWaiters);
|
|
119
|
+
return React.createElement('span', null, renderState.value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let renderer;
|
|
123
|
+
await act(async () => {
|
|
124
|
+
renderer = TestRenderer.create(React.createElement(Probe));
|
|
125
|
+
});
|
|
126
|
+
await aliceProvider.connect();
|
|
127
|
+
await bobProvider.connect();
|
|
128
|
+
await waitFor(() => aliceProvider.getPeerIds().includes('bob') && bobProvider.getPeerIds().includes('alice'));
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return await callback({
|
|
132
|
+
alice,
|
|
133
|
+
bob,
|
|
134
|
+
aliceProvider,
|
|
135
|
+
bobProvider,
|
|
136
|
+
readRendered: () => renderState.value,
|
|
137
|
+
renderCount: () => renderState.renders,
|
|
138
|
+
waitForRender: (expected) => waitForRenderValue(renderState, renderWaiters, expected)
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
await act(async () => {
|
|
142
|
+
renderer?.unmount();
|
|
143
|
+
});
|
|
144
|
+
await bobProvider.disconnect();
|
|
145
|
+
await aliceProvider.disconnect();
|
|
146
|
+
await server.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createProvider(doc, peerId, url) {
|
|
151
|
+
return createCrdtWebSocketProvider(
|
|
152
|
+
createCrdtSyncEndpoint(doc, { documentId: 'frontier-react-e2e', senderId: peerId, actorRangeSync: true }),
|
|
153
|
+
{
|
|
154
|
+
url,
|
|
155
|
+
documentId: 'frontier-react-e2e',
|
|
156
|
+
peerId,
|
|
157
|
+
WebSocket,
|
|
158
|
+
heartbeatIntervalMs: 0,
|
|
159
|
+
syncOnConnect: true,
|
|
160
|
+
autoSyncOnPeerJoin: true
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function summarizeScenario(fixture, samples) {
|
|
166
|
+
const total = summarize(samples.map((sample) => sample.totalUs));
|
|
167
|
+
const localCommit = summarize(samples.map((sample) => sample.localCommitUs));
|
|
168
|
+
const syncCall = summarize(samples.map((sample) => sample.syncCallUs));
|
|
169
|
+
const renderWait = summarize(samples.map((sample) => sample.renderWaitUs));
|
|
170
|
+
const bytes = summarize(samples.map((sample) => sample.updateBytes));
|
|
171
|
+
const patchOps = summarize(samples.map((sample) => sample.patchOps));
|
|
172
|
+
return {
|
|
173
|
+
fixture,
|
|
174
|
+
iterations: samples.length,
|
|
175
|
+
totalMedianUs: round(total.median),
|
|
176
|
+
totalP95Us: round(total.p95),
|
|
177
|
+
localCommitMedianUs: round(localCommit.median),
|
|
178
|
+
syncCallMedianUs: round(syncCall.median),
|
|
179
|
+
renderWaitMedianUs: round(renderWait.median),
|
|
180
|
+
updateBytesMedian: round(bytes.median),
|
|
181
|
+
patchOpsMedian: round(patchOps.median)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function summarize(values) {
|
|
186
|
+
const sorted = values.slice().sort((left, right) => left - right);
|
|
187
|
+
return {
|
|
188
|
+
median: percentile(sorted, 0.5),
|
|
189
|
+
p95: percentile(sorted, 0.95)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function finish(packageName, rows) {
|
|
194
|
+
const report = {
|
|
195
|
+
package: packageName,
|
|
196
|
+
benchmark: 'full-client-server-react-flow',
|
|
197
|
+
version: readPackageVersion(),
|
|
198
|
+
generatedAt: new Date().toISOString(),
|
|
199
|
+
node: process.version,
|
|
200
|
+
platform: process.platform + ' ' + process.arch,
|
|
201
|
+
iterations,
|
|
202
|
+
warmup,
|
|
203
|
+
rows
|
|
204
|
+
};
|
|
205
|
+
if (outPath) {
|
|
206
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
207
|
+
fs.writeFileSync(outPath, JSON.stringify(report, null, 2) + '\n');
|
|
208
|
+
}
|
|
209
|
+
printReport(report);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function printReport(report) {
|
|
213
|
+
console.log(report.package + ' full client/server React benchmark');
|
|
214
|
+
console.log('Node ' + report.node + ' on ' + report.platform + ', iterations=' + iterations + ', warmup=' + warmup);
|
|
215
|
+
console.log('These are Frontier-only package measurements, not competitor comparisons.');
|
|
216
|
+
console.log('');
|
|
217
|
+
console.log(
|
|
218
|
+
padRight('Fixture', 42) +
|
|
219
|
+
padLeft('Total med', 12) +
|
|
220
|
+
padLeft('Total p95', 12) +
|
|
221
|
+
padLeft('Commit', 10) +
|
|
222
|
+
padLeft('Sync', 10) +
|
|
223
|
+
padLeft('Render', 10) +
|
|
224
|
+
padLeft('Bytes', 8)
|
|
225
|
+
);
|
|
226
|
+
for (const row of report.rows) {
|
|
227
|
+
console.log(
|
|
228
|
+
padRight(row.fixture, 42) +
|
|
229
|
+
padLeft(formatUs(row.totalMedianUs), 12) +
|
|
230
|
+
padLeft(formatUs(row.totalP95Us), 12) +
|
|
231
|
+
padLeft(formatUs(row.localCommitMedianUs), 10) +
|
|
232
|
+
padLeft(formatUs(row.syncCallMedianUs), 10) +
|
|
233
|
+
padLeft(formatUs(row.renderWaitMedianUs), 10) +
|
|
234
|
+
padLeft(String(row.updateBytesMedian), 8)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (outPath) console.log('\nwrote ' + path.relative(rootDir, outPath));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function waitFor(predicate, timeoutMs = 1500) {
|
|
241
|
+
const start = performance.now();
|
|
242
|
+
while (performance.now() - start < timeoutMs) {
|
|
243
|
+
if (predicate()) return;
|
|
244
|
+
await delay(1);
|
|
245
|
+
}
|
|
246
|
+
throw new Error('timed out waiting for full client/server flow');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function waitForRenderValue(renderState, waiters, expected, timeoutMs = 1500) {
|
|
250
|
+
if (renderState.value === expected) return Promise.resolve();
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const waiter = {
|
|
253
|
+
expected,
|
|
254
|
+
resolve: () => {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
resolve();
|
|
257
|
+
},
|
|
258
|
+
reject: (error) => {
|
|
259
|
+
clearTimeout(timeout);
|
|
260
|
+
reject(error);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const timeout = setTimeout(() => {
|
|
264
|
+
waiters.delete(waiter);
|
|
265
|
+
reject(new Error(`timed out waiting for React render value ${JSON.stringify(expected)}`));
|
|
266
|
+
}, timeoutMs);
|
|
267
|
+
waiters.add(waiter);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function resolveRenderWaiters(value, waiters) {
|
|
272
|
+
for (const waiter of Array.from(waiters)) {
|
|
273
|
+
if (value === waiter.expected) {
|
|
274
|
+
waiters.delete(waiter);
|
|
275
|
+
waiter.resolve();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function assertRendered(actual, expected) {
|
|
281
|
+
if (actual !== expected) throw new Error(`remote React render mismatch: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function serverUrl(server) {
|
|
285
|
+
const address = server.address();
|
|
286
|
+
if (!address || typeof address !== 'object' || !('port' in address)) throw new Error('invalid websocket server address');
|
|
287
|
+
return `ws://127.0.0.1:${address.port}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function delay(ms) {
|
|
291
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function micros(ms) {
|
|
295
|
+
return ms * 1000;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function percentile(sorted, fraction) { return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * fraction) - 1))]; }
|
|
299
|
+
function readPackageVersion() { return JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')).version; }
|
|
300
|
+
function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === '--iterations') out.iterations = argv[++i]; else if (arg === '--warmup') out.warmup = argv[++i]; else if (arg === '--out') out.out = argv[++i]; else if (arg === '--help' || arg === '-h') { console.log('Usage: npm run bench:e2e -- [--iterations 80] [--warmup 12] [--out benchmarks/results/e2e-flow-latest.json]'); process.exit(0); } else throw new Error('unknown argument: ' + arg); } return out; }
|
|
301
|
+
function readPositiveInt(value, fallback) { if (value === undefined) return fallback; const number = Number(value); if (!Number.isInteger(number) || number <= 0) throw new Error('expected positive integer, got ' + value); return number; }
|
|
302
|
+
function round(value) { return Math.round(value * 100) / 100; }
|
|
303
|
+
function formatUs(value) { return value >= 1000 ? (value / 1000).toFixed(2) + ' ms' : value.toFixed(2) + ' us'; }
|
|
304
|
+
function padRight(value, width) { return String(value).padEnd(width); }
|
|
305
|
+
function padLeft(value, width) { return String(value).padStart(width); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shapeshift-labs/frontier-react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "React external-store hooks and adapters for Frontier state, cache, and CRDT surfaces.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"dist",
|
|
59
59
|
"README.md",
|
|
60
60
|
"LICENSE",
|
|
61
|
-
"benchmarks/package-bench.mjs"
|
|
61
|
+
"benchmarks/package-bench.mjs",
|
|
62
|
+
"benchmarks/e2e-flow.mjs"
|
|
62
63
|
],
|
|
63
64
|
"engines": {
|
|
64
65
|
"node": ">=18"
|
|
@@ -70,11 +71,15 @@
|
|
|
70
71
|
"react": ">=18.0.0"
|
|
71
72
|
},
|
|
72
73
|
"devDependencies": {
|
|
74
|
+
"@shapeshift-labs/frontier-crdt": "^0.1.2",
|
|
75
|
+
"@shapeshift-labs/frontier-crdt-sync": "^0.1.4",
|
|
76
|
+
"@shapeshift-labs/frontier-crdt-websocket": "^0.1.2",
|
|
73
77
|
"@types/node": "^24.10.1",
|
|
74
78
|
"@types/react": "^18.3.12",
|
|
75
79
|
"react": "^18.3.1",
|
|
76
80
|
"react-test-renderer": "^18.3.1",
|
|
77
|
-
"typescript": "^6.0.3"
|
|
81
|
+
"typescript": "^6.0.3",
|
|
82
|
+
"ws": "^8.21.0"
|
|
78
83
|
},
|
|
79
84
|
"scripts": {
|
|
80
85
|
"build": "node build.mjs",
|
|
@@ -83,6 +88,7 @@
|
|
|
83
88
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p test/tsconfig.json --noEmit",
|
|
84
89
|
"fuzz": "npm run build && node test/fuzz.mjs",
|
|
85
90
|
"bench": "npm run build && node benchmarks/package-bench.mjs",
|
|
91
|
+
"bench:e2e": "npm run build && node benchmarks/e2e-flow.mjs",
|
|
86
92
|
"pack:dry": "npm pack --dry-run"
|
|
87
93
|
},
|
|
88
94
|
"keywords": [
|