Add react-native support (#63)
Some checks failed
Publish / build (push) Waiting to run
Publish / publish-crate (push) Blocked by required conditions
Publish / publish-npm (push) Blocked by required conditions
Publish / publish-pypi (push) Blocked by required conditions
Check / build (push) Has been cancelled

Reviewed-on: https://home.schmelczer.dev/git/git/andras/reconcile/pulls/63
This commit is contained in:
Andras Schmelczer 2026-05-31 20:28:20 +01:00
parent 08e7d824f4
commit a8fbac6934
10 changed files with 907 additions and 339 deletions

3
.gitignore vendored
View file

@ -10,5 +10,8 @@ node_modules
# WebPack build output # WebPack build output
dist dist
# Generated wasm-bindgen bundler + wasm2js output for the React Native build
pkg-rn
# Python virtual environment # Python virtual environment
.venv .venv

View file

@ -80,6 +80,13 @@ console.log(result.text); // "Hi beautiful world"
See the [example website source](examples/website/src/index.ts) for a more complex example, or the [advanced examples document](docs/advanced-ts.md). See the [example website source](examples/website/src/index.ts) for a more complex example, or the [advanced examples document](docs/advanced-ts.md).
#### React Native (Hermes)
React Native's default engine, Hermes, does not expose a runtime `WebAssembly`
global, so the WebAssembly build cannot run there. For React Native, the package
ships a pure-JavaScript build produced by [Binaryen's `wasm2js`](https://github.com/WebAssembly/binaryen)
via its `react-native` entry point.
### Python ### Python
Install via uv or pip: Install via uv or pip:

View file

@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"binaryen": "^123.0.0",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"reconcile-text": "file:../pkg", "reconcile-text": "file:../pkg",
@ -65,6 +66,7 @@
"version": "7.28.0", "version": "7.28.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
@ -1656,6 +1658,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1682,6 +1685,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@ -1908,6 +1912,24 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/binaryen": {
"version": "123.0.0",
"resolved": "https://registry.npmjs.org/binaryen/-/binaryen-123.0.0.tgz",
"integrity": "sha512-/hls/a309aZCc0itqP6uhoR+5DsKSlJVfB8Opd2BY9Ndghs84IScTunlyidyF4r2Xe3lQttnfBNIDjaNpj6mTw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"wasm-as": "bin/wasm-as",
"wasm-ctor-eval": "bin/wasm-ctor-eval",
"wasm-dis": "bin/wasm-dis",
"wasm-merge": "bin/wasm-merge",
"wasm-metadce": "bin/wasm-metadce",
"wasm-opt": "bin/wasm-opt",
"wasm-reduce": "bin/wasm-reduce",
"wasm-shell": "bin/wasm-shell",
"wasm2js": "bin/wasm2js"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@ -1950,6 +1972,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3053,6 +3076,7 @@
"integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.3.0", "@jest/core": "30.3.0",
"@jest/types": "30.3.0", "@jest/types": "30.3.0",
@ -4936,6 +4960,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5072,6 +5097,7 @@
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@ -5119,6 +5145,7 @@
"version": "6.0.1", "version": "6.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.6.1", "@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1", "@webpack-cli/configtest": "^3.0.1",

View file

@ -4,6 +4,7 @@
"description": "Intelligent 3-way text merging with automated conflict resolution", "description": "Intelligent 3-way text merging with automated conflict resolution",
"main": "dist/reconcile.node.js", "main": "dist/reconcile.node.js",
"browser": "dist/reconcile.web.js", "browser": "dist/reconcile.web.js",
"react-native": "dist/reconcile.rn.js",
"keywords": [ "keywords": [
"text editing", "text editing",
"sync", "sync",
@ -31,12 +32,13 @@
"dist/**/*" "dist/**/*"
], ],
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "node scripts/build-rn.mjs && webpack --mode production",
"format": "prettier --write \"./**/*.(ts|scss|json|html)\"", "format": "prettier --write \"./**/*.(ts|mjs|scss|json|html)\"",
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest" "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"binaryen": "^123.0.0",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"reconcile-text": "file:../pkg", "reconcile-text": "file:../pkg",

View file

@ -0,0 +1,307 @@
// Generates `pkg-rn/`: a React Native / Hermes-compatible build of the
// wasm-bindgen bindings in which the WebAssembly module is replaced by its
// wasm2js (pure-JS) translation.
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
const here = dirname(fileURLToPath(import.meta.url));
const reconcileJsDir = resolve(here, '..');
const repoRoot = resolve(reconcileJsDir, '..');
const releaseWasm = resolve(
repoRoot,
'target/wasm32-unknown-unknown/release/reconcile_text.wasm'
);
const outDir = resolve(reconcileJsDir, 'pkg-rn');
const bgWasm = resolve(outDir, 'reconcile_text_bg.wasm');
const bgWasmJs = resolve(outDir, 'reconcile_text_bg.wasm.js');
const loweredWasm = resolve(outDir, '_lowered.wasm');
const entryJs = resolve(outDir, 'reconcile_text.js');
const wasmOpt = resolve(reconcileJsDir, 'node_modules/.bin/wasm-opt');
const wasm2js = resolve(reconcileJsDir, 'node_modules/.bin/wasm2js');
function run(cmd, args) {
execFileSync(cmd, args, { stdio: 'inherit' });
}
// Locate the wasm-bindgen CLI. It MUST match the `wasm-bindgen` crate version pinned
// in Cargo.toml: a mismatched CLI emits bindings the runtime can't use. So we resolve
// the required version first and verify every candidate against it, failing loudly
// rather than silently falling back to whatever other version happens to be around.
function findWasmBindgen() {
const cargoToml = readFileSync(resolve(repoRoot, 'Cargo.toml'), 'utf8');
const wanted = cargoToml.match(
/wasm-bindgen\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"/
)?.[1];
if (!wanted) {
throw new Error(
'[build-rn] Could not parse the pinned wasm-bindgen version from Cargo.toml, so ' +
'the required CLI version is unknown. Has the dependency declaration changed?'
);
}
// 1. On PATH: accept it only if its version matches the pin.
let onPath = null;
try {
onPath = execFileSync('which', ['wasm-bindgen'], { encoding: 'utf8' }).trim();
} catch {
/* not on PATH; try the wasm-pack cache next */
}
if (onPath) {
const version = execFileSync(onPath, ['--version'], { encoding: 'utf8' }).match(
/\d+\.\d+\.\d+/
)?.[0];
if (version !== wanted) {
throw new Error(
`[build-rn] wasm-bindgen on PATH (${onPath}) is ${version ?? 'an unknown version'}, ` +
`but Cargo.toml pins ${wanted}. Install the matching CLI ` +
`(\`cargo install wasm-bindgen-cli --version ${wanted}\`) or remove the mismatched one.`
);
}
return onPath;
}
const cacheRoots = [
resolve(homedir(), 'Library/Caches/.wasm-pack'),
resolve(homedir(), '.cache/.wasm-pack'),
];
for (const root of cacheRoots) {
if (!existsSync(root)) {
continue;
}
for (const entry of readdirSync(root)) {
const candidate = resolve(root, entry, 'wasm-bindgen');
if (!existsSync(candidate)) {
continue;
}
let version;
try {
version = execFileSync(candidate, ['--version'], { encoding: 'utf8' }).match(
/\d+\.\d+\.\d+/
)?.[0];
} catch {
continue; // not an invokable wasm-bindgen; ignore
}
if (version === wanted) {
return candidate;
}
}
}
throw new Error(
`[build-rn] No wasm-bindgen ${wanted} found on PATH or in the wasm-pack cache. ` +
'Run `wasm-pack build --target web --features wasm` first (it caches the matching ' +
`wasm-bindgen), or \`cargo install wasm-bindgen-cli --version ${wanted}\`.`
);
}
if (!existsSync(releaseWasm)) {
throw new Error(
`Missing ${releaseWasm}.\nRun \`wasm-pack build --target web --features wasm\` from the repo root first.`
);
}
console.log('[build-rn] generating bundler-target bindings with wasm-bindgen');
rmSync(outDir, { recursive: true, force: true });
const wasmBindgen = findWasmBindgen();
run(wasmBindgen, ['--target', 'bundler', '--out-dir', outDir, releaseWasm]);
// --- Patch wasm-bindgen's cached-memory getters for wasm2js -----------------
//
// wasm-bindgen caches typed-array / DataView views over `wasm.memory.buffer` and
// only re-creates them when it detects the heap grew. It detects a grow by looking
// for ArrayBuffer *detachment*: a real `WebAssembly.Memory.grow()` detaches the old
// buffer (its `byteLength` becomes 0 and `.detached` becomes true), and those are the
// only signals the generated getters check:
// - getUint8ArrayMemory0(): refreshes when `byteLength === 0` (detach only)
// - getDataViewMemory0(): refreshes when `.detached === true`, OR when the buffer
// identity changed but only `if (.detached === undefined)` — i.e. that identity
// fallback runs solely on engines lacking `ArrayBuffer.prototype.detached`.
//
// wasm2js grows differently: `__wasm_memory_grow` (in reconcile_text_bg.wasm.js)
// allocates a NEW ArrayBuffer, copies the old heap into it, and reassigns
// `memory.buffer` WITHOUT ever detaching the old buffer. So the old buffer keeps
// `byteLength > 0` and `.detached === false`, and on modern engines that DO expose
// `ArrayBuffer.prototype.detached` (Node 25+, current Hermes) the identity fallback is
// gated off. Net effect: after a grow the getters keep returning views over the stale
// pre-grow buffer, silently corrupting any operation large enough to grow the heap.
// Small inputs never grow, so this escapes naive testing.
//
// WHY WE PATCH INSTEAD OF CONFIGURING.
// This is not fixed or configurable upstream: wasm-bindgen has no wasm2js / asm.js /
// React Native / "no-WebAssembly" target (every target assumes real WebAssembly
// detach-on-grow semantics), there is no flag to force buffer-identity comparison, and
// the getter-generation logic (crates/cli-support/src/js/mod.rs `memview`) is
// byte-for-byte identical from the pinned 0.2.114 through the latest release and
// `main`. The non-detaching-grow case is not even a tracked upstream issue. Rewriting
// the generated glue is therefore the only available fix: the two replacements below
// make BOTH getters also refresh on a buffer-identity change
// (`buffer !== wasm.memory.buffer`), which is the one signal wasm2js does give.
//
// Each replacement is asserted independently. If a future wasm-bindgen reshapes one
// getter but not the other, we MUST fail the build rather than ship a half-patched
// module whose un-patched getter corrupts large inputs. The post-build self-test at
// the bottom of this file is the backstop that proves the result survives a real grow.
const bgJsPath = resolve(outDir, 'reconcile_text_bg.js');
let bgJs = readFileSync(bgJsPath, 'utf8');
// (1) Uint8Array getter: append an unconditional buffer-identity check to the
// `byteLength === 0` detach guard (upstream has no identity check here at all).
const byteLengthGuard = /(cached\w*Memory0)\.byteLength === 0/g;
const byteLengthHits = bgJs.match(byteLengthGuard)?.length ?? 0;
if (byteLengthHits === 0) {
throw new Error(
`[build-rn] Could not find the Uint8Array \`byteLength === 0\` growth guard in ` +
`${bgJsPath} to patch for wasm2js. The wasm-bindgen output shape changed; update ` +
'this patch (see crates/cli-support/src/js/mod.rs `memview`) — do NOT ship an ' +
'unpatched getter, it will corrupt large inputs under wasm2js.'
);
}
bgJs = bgJs.replace(
byteLengthGuard,
'$1.byteLength === 0 || $1.buffer !== wasm.memory.buffer'
);
// (2) DataView getter: drop the `detached === undefined &&` prefix so the existing
// buffer-identity check runs on every runtime, not only legacy ones.
const gatedGuard =
/(cached\w*Memory0)\.buffer\.detached === undefined && \1\.buffer !== wasm\.memory\.buffer/g;
const gatedHits = bgJs.match(gatedGuard)?.length ?? 0;
if (gatedHits === 0) {
throw new Error(
`[build-rn] Could not find the DataView \`detached === undefined\`-gated buffer-identity ` +
`check in ${bgJsPath} to un-gate for wasm2js. The wasm-bindgen output shape changed; ` +
'update this patch (see crates/cli-support/src/js/mod.rs `memview`) — do NOT ship an ' +
'unpatched getter, it will corrupt large inputs under wasm2js.'
);
}
bgJs = bgJs.replace(gatedGuard, '$1.buffer !== wasm.memory.buffer');
writeFileSync(bgJsPath, bgJs);
// Post-MVP features that wasm2js cannot translate must be lowered to MVP first.
// reference-types stays enabled: it only covers the funcref table here, which
// wasm2js handles via call_indirect.
const featureFlags = [
'--enable-bulk-memory',
'--enable-sign-ext',
'--enable-nontrapping-float-to-int',
'--enable-mutable-globals',
'--enable-reference-types',
];
console.log('[build-rn] optimising and lowering to MVP with wasm-opt');
run(wasmOpt, [
...featureFlags,
'-O3',
'--signext-lowering',
'--llvm-memory-copy-fill-lowering',
'--llvm-nontrapping-fptoint-lowering',
bgWasm,
'-o',
loweredWasm,
]);
console.log('[build-rn] translating wasm -> JS with wasm2js');
run(wasm2js, ['--enable-reference-types', loweredWasm, '-o', bgWasmJs]);
console.log('[build-rn] wiring the JS translation into reconcile_text.js');
const entry = readFileSync(entryJs, 'utf8');
const rewired = entry.replace(
/from\s+(['"])\.\/reconcile_text_bg\.wasm\1/,
'from $1./reconcile_text_bg.wasm.js$1'
);
if (rewired === entry) {
throw new Error(
`Could not find the \`./reconcile_text_bg.wasm\` import in ${entryJs}; ` +
'the wasm-bindgen bundler output layout may have changed.'
);
}
writeFileSync(entryJs, rewired);
// The binary and the intermediate are no longer referenced; remove them so no
// bundler attempts to instantiate WebAssembly from this directory.
rmSync(bgWasm, { force: true });
rmSync(loweredWasm, { force: true });
// Mark the directory as ESM (matching the web `pkg/`) so Node and Jest treat
// these `.js` files as modules. `sideEffects` stays true because importing the
// entry runs `__wbg_set_wasm(...)`, which must not be tree-shaken away.
writeFileSync(
resolve(outDir, 'package.json'),
JSON.stringify({ type: 'module', sideEffects: true }, null, 2) + '\n'
);
// Backstop: import the freshly generated module and prove it survives a heap grow.
// The patches above are matched by regex against wasm-bindgen output; a silently
// mis-applied patch (or a wasm-bindgen change we matched too loosely) would leave a
// getter reading the stale pre-grow buffer and corrupt large inputs only. Rather than
// trust the regexes, we force a grow here and assert a byte-exact round-trip, so a
// broken bundle fails the build instead of reaching a React Native consumer.
async function selfTest() {
// Importing the entry runs `__wbg_set_wasm(...)`, initialising the wasm2js module.
const api = await import(pathToFileURL(entryJs).href);
// Same module instance (Node caches by resolved path), so this `memory` is the heap
// the API operates on; its `.buffer` getter reflects the current (post-grow) buffer.
const { memory } = await import(pathToFileURL(bgWasmJs).href);
// ~100 KB of distinct tokens. The diff working set amplifies the input many-fold
// (a ~50 KB input already forces dozens of grows), so this reliably grows the heap
// well past wasm2js's ~1 MB initial allocation while staying fast. A tiny parent
// keeps the edit distance — and therefore the runtime — small.
const tokens = [];
for (let i = 0; i < 10000; i++) {
tokens.push(`token-${i}`);
}
const target = tokens.join(' ');
const parent = 'reconcile self-test';
const heapBefore = memory.buffer.byteLength;
// Stale post-grow reads surface either as an out-of-bounds throw or as silently
// wrong bytes, so handle both: a throw here is itself the failure signal.
let roundTripped;
try {
const changed = new api.TextWithCursors(target, []);
const compact = api
.diff(parent, changed, 'Word')
// This build's `undiff` rejects BigInt; normalise exactly as src/core.ts does.
.map((item) => (typeof item === 'bigint' ? Number(item) : item));
changed.free();
roundTripped = api.undiff(parent, compact, 'Word');
} catch (cause) {
throw new Error(
'[build-rn] self-test crashed during a large diff/undiff round-trip (after the heap ' +
'grew). This is the signature of unpatched wasm2js cached-memory getters reading the ' +
'stale pre-grow buffer. The growth patch is not taking effect. Refusing to ship this ' +
'React Native bundle.',
{ cause }
);
}
const heapAfter = memory.buffer.byteLength;
if (heapAfter <= heapBefore) {
throw new Error(
`[build-rn] self-test did not grow the wasm heap (stayed at ${heapBefore} bytes), ` +
'so it cannot validate the memory-growth patch. Enlarge the self-test input.'
);
}
if (roundTripped !== target) {
throw new Error(
'[build-rn] self-test FAILED: diff/undiff round-trip did not match after a heap grow. ' +
'The patched wasm2js cached-memory getters are returning stale/corrupt data — the ' +
'growth patch is not taking effect. Refusing to ship this React Native bundle.'
);
}
}
console.log('[build-rn] self-testing the patched module (forces a heap grow)');
await selfTest();
console.log('[build-rn] done -> pkg-rn/');

400
reconcile-js/src/core.ts Normal file
View file

@ -0,0 +1,400 @@
// Shared, platform-agnostic wrapper around the generated wasm-bindgen surface.
//
// The actual wasm bindings are injected by a platform-specific entrypoint:
// - `index.ts` (web/node) instantiates the real WebAssembly module lazily
// on first use via `initSync`.
// - `index.rn.ts` (React Native / Hermes) links a wasm2js (pure-JS)
// implementation, since Hermes does not expose a runtime
// `WebAssembly` global. See `scripts/build-rn.mjs`.
type WasmModule = typeof import('reconcile-text');
/**
* The generated wasm-bindgen surface this library wraps, plus a hook to make
* sure the underlying module is ready. Supplied by a platform entrypoint.
*/
export interface WasmBackend {
CursorPosition: WasmModule['CursorPosition'];
TextWithCursors: WasmModule['TextWithCursors'];
reconcile: WasmModule['reconcile'];
reconcileWithHistory: WasmModule['reconcileWithHistory'];
diff: WasmModule['diff'];
undiff: WasmModule['undiff'];
/**
* Make the wasm module ready for use. Invoked before every operation, so it
* must be cheap and idempotent (a no-op once initialised).
*/
ensureReady(): void;
}
// Define the enum values as a const array to avoid duplication
const BUILTIN_TOKENIZERS = ['Character', 'Line', 'Markdown', 'Word'] as const;
/**
* Tokenisation strategies for text merging.
*
* These correspond to the built-in tokenizers available in the underlying WASM module.
*/
export type BuiltinTokenizer = (typeof BUILTIN_TOKENIZERS)[number];
/**
* History classification for text spans in merge results.
*
* Indicates the origin of each text span in the merged document.
*/
export type History =
| 'Unchanged'
| 'AddedFromLeft'
| 'AddedFromRight'
| 'RemovedFromLeft'
| 'RemovedFromRight';
/**
* Represents a text document with associated cursor positions.
*
* This interface is used both as input to reconcile functions (to specify where
* cursors are positioned in the original documents) and as output (with cursors
* automatically repositioned after merging).
*/
export interface TextWithCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be empty if there are no cursors to track.
* Each cursor has a unique ID and position.
*/
cursors: CursorPosition[];
}
/**
* Like `TextWithCursors`, but cursors may be null or undefined (treated as empty).
* Used as input where cursor tracking is optional.
*/
export interface TextWithOptionalCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be null, undefined, or empty
* if there are no cursors to track. Each cursor has a unique ID and position.
*/
cursors: null | undefined | CursorPosition[];
}
/**
* Represents a cursor position within a text document.
*
* Cursors are automatically repositioned during text merging to maintain their
* relative positions as text is inserted, deleted, or modified around them.
*/
export interface CursorPosition {
/** Unique identifier for the cursor (can be any number, must be unique within the document) */
id: number;
/** Character position in the text, 0-based index from the beginning of the document */
position: number;
}
/**
* Represents a merged text document with cursor positions and detailed change history.
*
* This is the return type of `reconcileWithHistory()` and provides complete information
* about how the merge was performed, including which parts of the final text came from
* which source documents.
*/
export interface TextWithCursorsAndHistory {
/** The merged document's entire content */
text: string;
/**
* Array of cursor positions within the merged text. Can be empty if there are no cursors to track.
* All cursors are automatically repositioned from the left and right documents.
*/
cursors: CursorPosition[];
/**
* Detailed provenance information showing the origin of each text span in the result.
* Each span indicates whether it was unchanged, added from left, added from right, etc.
*/
history: SpanWithHistory[];
}
/**
* Represents a span of text in the merged result with its change history.
*
* This shows exactly which source document contributed each piece of text to the
* final merged result. Useful for understanding merge decisions and creating
* visualisations of how documents were combined.
*/
export interface SpanWithHistory {
/** The text content of this span */
text: string;
/** The origin of this text span in the merge result */
history: History;
}
/** The public, synchronous API surface, identical across platforms. */
export interface ReconcileApi {
/**
* Merges three versions of text using intelligent conflict resolution.
*
* This is the primary function for 3-way text merging. Unlike traditional merge tools
* that produce conflict markers, this function automatically resolves conflicts by
* applying both sets of changes where possible.
*
* @param original - The original/base version of the text that both sides diverged from
* @param left - The left version of the text (either string or TextWithCursors with cursor positions)
* @param right - The right version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose),
* "Character" (fine-grained), "Line" (similar to git merge), or
* "Markdown" (splits on Markdown structure)
* @returns The reconciled text with automatically repositioned cursor positions
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world"; // Added "beautiful"
* const right = "Hi world"; // Changed "Hello" to "Hi"
*
* const result = reconcile(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* ```
*/
reconcile(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer?: BuiltinTokenizer
): TextWithCursors;
/**
* Generates a compact diff representation between an original and changed text.
*
* These can be parsed and unpacked using the `undiff` function or the Rust crate's EditedText::from_diff.
* Cursor positions are omitted from the diff result.
*
* This function computes the differences between two versions of text and returns
* a compact representation of those changes.
*
* @param original - The original/base version of the text
* @param changed - The modified version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns An array of inserts (strings), deletes (negative integers), and retained spans (positive integers).
*/
diff(
original: string,
changed: string | TextWithOptionalCursors,
tokenizer?: BuiltinTokenizer
): Array<number | string>;
/**
* Applies a compact diff to an original text to reconstruct the changed version.
*
* This function takes an original text and a compact diff representation (as produced
* by the `diff` function) and reconstructs the modified text.
*
* @param original - The original/base version of the text
* @param diff - The compact diff array (inserts as strings, deletes as negative integers, retained spans as positive integers)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns The reconstructed changed text as a string.
*/
undiff(
original: string,
diff: Array<number | bigint | string>,
tokenizer?: BuiltinTokenizer
): string;
/**
* Merges three versions of text and returns detailed provenance information.
*
* This function behaves like `reconcile()` but also provides
* detailed historical information about the origin of each text span in the result.
* This is valuable for understanding how the merge was performed and which changes
* came from which source.
*
* Note: Computing the history is computationally more expensive than the basic merge.
*
* @param original - The original/base version of the text that both sides diverged from
* @param left - The left version of the text (either string or TextWithCursors with cursor positions)
* @param right - The right version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose),
* "Character" (fine-grained), "Line" (similar to git merge), or
* "Markdown" (splits on Markdown structure)
* @returns The reconciled text with cursor positions and detailed change history
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world";
* const right = "Hi world";
*
* const result = reconcileWithHistory(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* console.log(result.history); // Array of SpanWithHistory objects showing change origins
* ```
*/
reconcileWithHistory(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer?: BuiltinTokenizer
): TextWithCursorsAndHistory;
}
const UNSUPPORTED_TOKENIZER_ERROR = `Unsupported tokenizer, only ${BUILTIN_TOKENIZERS.join(
', '
)} are supported`;
/**
* Build the public {@link ReconcileApi} on top of a {@link WasmBackend}.
*
* Each operation calls `backend.ensureReady()` first, then marshals JS values
* into the wasm representation, invokes the binding, and frees the wasm-side
* objects. The behaviour is identical regardless of whether the backend is a
* real WebAssembly module or its wasm2js translation.
*/
export function makeReconcileApi(backend: WasmBackend): ReconcileApi {
function assertTokenizer(tokenizer: BuiltinTokenizer): void {
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
}
function toWasmTextWithCursors(text: string | TextWithOptionalCursors) {
const isInputString = typeof text === 'string';
const innerText = isInputString ? text : text.text;
const innerCursors = isInputString ? [] : (text.cursors ?? []);
return new backend.TextWithCursors(
innerText,
innerCursors.map(({ id, position }) => new backend.CursorPosition(id, position))
);
}
function toTextWithCursors(textWithCursor: {
text(): string;
cursors(): Array<{ id(): number; characterIndex(): number; free(): void }>;
}): TextWithCursors {
const wasmCursors = textWithCursor.cursors();
const cursors = wasmCursors.map((cursor) => ({
id: cursor.id(),
position: cursor.characterIndex(),
}));
for (const cursor of wasmCursors) {
cursor.free();
}
return {
text: textWithCursor.text(),
cursors,
};
}
function toSpanWithHistory(span: {
text(): string;
history(): History;
free(): void;
}): SpanWithHistory {
const result = {
text: span.text(),
history: span.history(),
};
span.free();
return result;
}
function reconcile(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursors {
backend.ensureReady();
assertTokenizer(tokenizer);
const leftCursor = toWasmTextWithCursors(left);
const rightCursor = toWasmTextWithCursors(right);
const result = backend.reconcile(original, leftCursor, rightCursor, tokenizer);
leftCursor.free();
rightCursor.free();
const jsResult = toTextWithCursors(result);
result.free();
return jsResult;
}
function diff(
original: string,
changed: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): Array<number | string> {
backend.ensureReady();
assertTokenizer(tokenizer);
const changedWasm = toWasmTextWithCursors(changed);
const result = backend.diff(original, changedWasm, tokenizer);
changedWasm.free();
return result.map((item) => (typeof item === 'bigint' ? Number(item) : item));
}
function undiff(
original: string,
diffValue: Array<number | bigint | string>,
tokenizer: BuiltinTokenizer = 'Word'
): string {
backend.ensureReady();
assertTokenizer(tokenizer);
// The real-WebAssembly backend's `diff` emits BigInt spans, whereas the
// wasm2js (React Native) backend rejects BigInt outright. Normalise to
// plain numbers - exactly as `diff` does on the way out - so a `diff`
// result round-trips through `undiff` identically on every platform.
return backend.undiff(
original,
diffValue.map((item) => (typeof item === 'bigint' ? Number(item) : item)),
tokenizer
);
}
function reconcileWithHistory(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursorsAndHistory {
backend.ensureReady();
assertTokenizer(tokenizer);
const leftCursor = toWasmTextWithCursors(left);
const rightCursor = toWasmTextWithCursors(right);
const result = backend.reconcileWithHistory(
original,
leftCursor,
rightCursor,
tokenizer
);
leftCursor.free();
rightCursor.free();
const jsResult = toTextWithCursors(result);
const history = result.history().map(toSpanWithHistory);
result.free();
return {
...jsResult,
history,
};
}
return { reconcile, diff, undiff, reconcileWithHistory };
}

View file

@ -0,0 +1,47 @@
// React Native entrypoint (resolved via the `react-native` package field).
//
// Hermes — the default React Native engine since RN 0.84 / Expo SDK 56 — does
// not expose a runtime `WebAssembly` global, so the normal `new
// WebAssembly.Module(...)` path used by `index.ts` throws
// `ReferenceError: Property 'WebAssembly' doesn't exist`.
//
// Instead we link a wasm2js translation of the module: pure JavaScript that
// needs no `WebAssembly` global and is instantiated synchronously at import
// time. The public API and its synchronous signatures are unchanged, so
// callers need no modification. The `pkg-rn` directory is generated by
// `scripts/build-rn.mjs`.
import {
CursorPosition as wasmCursorPosition,
TextWithCursors as wasmTextWithCursors,
reconcile as wasmReconcile,
reconcileWithHistory as wasmReconcileWithHistory,
diff as wasmDiff,
undiff as wasmUndiff,
} from '../pkg-rn/reconcile_text.js';
import { makeReconcileApi, type WasmBackend } from './core';
const backend: WasmBackend = {
CursorPosition: wasmCursorPosition,
TextWithCursors: wasmTextWithCursors,
reconcile: wasmReconcile,
reconcileWithHistory: wasmReconcileWithHistory,
diff: wasmDiff,
undiff: wasmUndiff,
// The wasm2js module initialises itself at import time, so this is a no-op.
ensureReady() {},
};
export const { reconcile, diff, undiff, reconcileWithHistory } =
makeReconcileApi(backend);
export type {
BuiltinTokenizer,
History,
CursorPosition,
TextWithCursors,
TextWithOptionalCursors,
TextWithCursorsAndHistory,
SpanWithHistory,
} from './core';

View file

@ -1,4 +1,5 @@
import { reconcile, reconcileWithHistory, diff, undiff } from './index'; import * as webApi from './index';
import * as rnApi from './index.rn';
import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector'; import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -17,7 +18,18 @@ afterEach(() => {
} }
}); });
describe('reconcile', () => { // `./index` is the web/node build (real WebAssembly); `./index.rn` is the React
// Native build (the wasm2js pure-JS translation). Both are thin backends over the
// same `src/core.ts` wrapper and expose an identical public API, so the behavioural
// suite below runs against both to guarantee they stay in lock-step.
const backends = [
{ name: 'web/node (WebAssembly)', api: webApi },
{ name: 'React Native (wasm2js)', api: rnApi },
];
describe.each(backends)('reconcile [$name]', ({ api }) => {
const { reconcile, reconcileWithHistory, diff, undiff } = api;
it('call reconcile without cursors', () => { it('call reconcile without cursors', () => {
expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world'); expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world');
}); });
@ -60,9 +72,26 @@ describe('reconcile', () => {
expect(result.text).toEqual('Hi world'); expect(result.text).toEqual('Hi world');
expect(result.history.length).toBeGreaterThan(0); expect(result.history.length).toBeGreaterThan(0);
}); });
it('undiff accepts bigint entries (per the Array<number | bigint | string> type)', () => {
const original = 'Hello world';
const changed = 'Hello cruel world';
// `diff` returns plain numbers; emulate a caller that supplies BigInt, which the
// public signature permits. The wasm2js build rejects raw BigInt, so the shared
// wrapper must normalise it — running this on both backends asserts the contract.
const withBigints = diff(original, changed).map((item) =>
typeof item === 'number' ? BigInt(item) : item
);
expect(withBigints.some((item) => typeof item === 'bigint')).toBe(true);
expect(undiff(original, withBigints)).toEqual(changed);
});
}); });
describe('test_diff_and_undiff_are_inverse', () => { describe.each(backends)('diff and undiff are inverse [$name]', ({ api }) => {
const { diff, undiff } = api;
const resourcesPath = path.join(__dirname, '../../tests/resources'); const resourcesPath = path.join(__dirname, '../../tests/resources');
const readFileSlice = (fileName: string, start: number, end: number): string => { const readFileSlice = (fileName: string, start: number, end: number): string => {
@ -93,3 +122,31 @@ describe('test_diff_and_undiff_are_inverse', () => {
}); });
}); });
}); });
// React-Native-only: Hermes exposes no `WebAssembly` global, which is the whole reason
// the RN entry point links a wasm2js build. Only the wasm2js backend can satisfy this.
describe('React Native (wasm2js) Hermes parity', () => {
const { reconcile, reconcileWithHistory, diff, undiff } = rnApi;
it('runs every operation with no WebAssembly global', () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'WebAssembly');
delete (globalThis as { WebAssembly?: unknown }).WebAssembly;
try {
expect((globalThis as { WebAssembly?: unknown }).WebAssembly).toBeUndefined();
expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world');
const changes = diff('Hello world', 'Hello cruel world');
expect(undiff('Hello world', changes)).toEqual('Hello cruel world');
expect(
reconcileWithHistory('Hello', 'Hello world', 'Hi world').history.length
).toBeGreaterThan(0);
} finally {
// Restore the global so the leak check and later suites are unaffected.
if (descriptor) {
Object.defineProperty(globalThis, 'WebAssembly', descriptor);
}
}
});
});

View file

@ -1,8 +1,7 @@
import { import {
CursorPosition as wasmCursorPosition, CursorPosition as wasmCursorPosition,
reconcile as wasmReconcile,
TextWithCursors as wasmTextWithCursors, TextWithCursors as wasmTextWithCursors,
SpanWithHistory as wasmSpanWithHistory, reconcile as wasmReconcile,
reconcileWithHistory as wasmReconcileWithHistory, reconcileWithHistory as wasmReconcileWithHistory,
diff as wasmDiff, diff as wasmDiff,
undiff as wasmUndiff, undiff as wasmUndiff,
@ -11,341 +10,40 @@ import {
import wasmBytes from 'reconcile-text/reconcile_text_bg.wasm'; import wasmBytes from 'reconcile-text/reconcile_text_bg.wasm';
// Define the enum values as const arrays to avoid duplication import { makeReconcileApi, type WasmBackend } from './core';
const BUILTIN_TOKENIZERS = ['Character', 'Line', 'Markdown', 'Word'] as const;
const HISTORY_VALUES = [
'Unchanged',
'AddedFromLeft',
'AddedFromRight',
'RemovedFromLeft',
'RemovedFromRight',
] as const;
/**
* Tokenisation strategies for text merging.
*
* These correspond to the built-in tokenizers available in the underlying WASM module.
*/
export type BuiltinTokenizer = (typeof BUILTIN_TOKENIZERS)[number];
/**
* History classification for text spans in merge results.
*
* Indicates the origin of each text span in the merged document.
*/
export type History = (typeof HISTORY_VALUES)[number];
/**
* Represents a text document with associated cursor positions.
*
* This interface is used both as input to reconcile functions (to specify where
* cursors are positioned in the original documents) and as output (with cursors
* automatically repositioned after merging).
*/
export interface TextWithCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be empty if there are no cursors to track.
* Each cursor has a unique ID and position.
*/
cursors: CursorPosition[];
}
/**
* Like `TextWithCursors`, but cursors may be null or undefined (treated as empty).
* Used as input where cursor tracking is optional.
*/
export interface TextWithOptionalCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be null, undefined, or empty
* if there are no cursors to track. Each cursor has a unique ID and position.
*/
cursors: null | undefined | CursorPosition[];
}
/**
* Represents a cursor position within a text document.
*
* Cursors are automatically repositioned during text merging to maintain their
* relative positions as text is inserted, deleted, or modified around them.
*/
export interface CursorPosition {
/** Unique identifier for the cursor (can be any number, must be unique within the document) */
id: number;
/** Character position in the text, 0-based index from the beginning of the document */
position: number;
}
/**
* Represents a merged text document with cursor positions and detailed change history.
*
* This is the return type of `reconcileWithHistory()` and provides complete information
* about how the merge was performed, including which parts of the final text came from
* which source documents.
*/
export interface TextWithCursorsAndHistory {
/** The merged document's entire content */
text: string;
/**
* Array of cursor positions within the merged text. Can be empty if there are no cursors to track.
* All cursors are automatically repositioned from the left and right documents.
*/
cursors: CursorPosition[];
/**
* Detailed provenance information showing the origin of each text span in the result.
* Each span indicates whether it was unchanged, added from left, added from right, etc.
*/
history: SpanWithHistory[];
}
/**
* Represents a span of text in the merged result with its change history.
*
* This shows exactly which source document contributed each piece of text to the
* final merged result. Useful for understanding merge decisions and creating
* visualisations of how documents were combined.
*/
export interface SpanWithHistory {
/** The text content of this span */
text: string;
/** The origin of this text span in the merge result */
history: History;
}
const UNSUPPORTED_TOKENIZER_ERROR = `Unsupported tokenizer, only ${BUILTIN_TOKENIZERS.join(
', '
)} are supported`;
let isInitialised = false; let isInitialised = false;
/** const backend: WasmBackend = {
* Merges three versions of text using intelligent conflict resolution. CursorPosition: wasmCursorPosition,
* TextWithCursors: wasmTextWithCursors,
* This is the primary function for 3-way text merging. Unlike traditional merge tools reconcile: wasmReconcile,
* that produce conflict markers, this function automatically resolves conflicts by reconcileWithHistory: wasmReconcileWithHistory,
* applying both sets of changes where possible. diff: wasmDiff,
* undiff: wasmUndiff,
* @param original - The original/base version of the text that both sides diverged from ensureReady() {
* @param left - The left version of the text (either string or TextWithCursors with cursor positions) if (isInitialised) {
* @param right - The right version of the text (either string or TextWithCursors with cursor positions) return;
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose), }
* "Character" (fine-grained), or "Line" (similar to git merge)
* @returns The reconciled text with automatically repositioned cursor positions
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world"; // Added "beautiful"
* const right = "Hi world"; // Changed "Hello" to "Hi"
*
* const result = reconcile(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* ```
*/
export function reconcile(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursors {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) { const wasmBinary = Uint8Array.from(atob(wasmBytes as unknown as string), (c) =>
throw new Error(UNSUPPORTED_TOKENIZER_ERROR); c.charCodeAt(0)
} );
initSync({ module: wasmBinary });
const leftCursor = toWasmTextWithCursors(left); isInitialised = true;
const rightCursor = toWasmTextWithCursors(right); },
};
const result = wasmReconcile(original, leftCursor, rightCursor, tokenizer); export const { reconcile, diff, undiff, reconcileWithHistory } =
makeReconcileApi(backend);
leftCursor.free(); export type {
rightCursor.free(); BuiltinTokenizer,
History,
const jsResult = toTextWithCursors(result); CursorPosition,
result.free(); TextWithCursors,
TextWithOptionalCursors,
return jsResult; TextWithCursorsAndHistory,
} SpanWithHistory,
} from './core';
/**
* Generates a compact diff representation between an original and changed text.
*
* These can be parsed and unpacked using the `undiff` function or the Rust crate's EditedText::from_diff.
* Cursor positions are omitted from the diff result.
*
* This function computes the differences between two versions of text and returns
* a compact representation of those changes.
*
* @param original - The original/base version of the text
* @param changed - The modified version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns An array of inserts (strings), deletes (negative integers), and retained spans (positive integers).
*/
export function diff(
original: string,
changed: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): Array<number | string> {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
const changedWasm = toWasmTextWithCursors(changed);
const result = wasmDiff(original, changedWasm, tokenizer);
changedWasm.free();
return result.map((item) => (typeof item === 'bigint' ? Number(item) : item));
}
/**
* Applies a compact diff to an original text to reconstruct the changed version.
*
* This function takes an original text and a compact diff representation (as produced
* by the `diff` function) and reconstructs the modified text.
*
* @param original - The original/base version of the text
* @param diff - The compact diff array (inserts as strings, deletes as negative integers, retained spans as positive integers)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns The reconstructed changed text as a string.
*/
export function undiff(
original: string,
diff: Array<number | bigint | string>,
tokenizer: BuiltinTokenizer = 'Word'
): string {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
return wasmUndiff(original, diff, tokenizer);
}
/**
* Merges three versions of text and returns detailed provenance information.
*
* This function behaves like `reconcile()` but also provides
* detailed historical information about the origin of each text span in the result.
* This is valuable for understanding how the merge was performed and which changes
* came from which source.
*
* Note: Computing the history is computationally more expensive than the basic merge.
*
* @param original - The original/base version of the text that both sides diverged from
* @param left - The left version of the text (either string or TextWithCursors with cursor positions)
* @param right - The right version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose),
* "Character" (fine-grained), or "Line" (similar to git merge)
* @returns The reconciled text with cursor positions and detailed change history
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world";
* const right = "Hi world";
*
* const result = reconcileWithHistory(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* console.log(result.history); // Array of SpanWithHistory objects showing change origins
* ```
*/
export function reconcileWithHistory(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursorsAndHistory {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
const leftCursor = toWasmTextWithCursors(left);
const rightCursor = toWasmTextWithCursors(right);
const result = wasmReconcileWithHistory(original, leftCursor, rightCursor, tokenizer);
leftCursor.free();
rightCursor.free();
const jsResult = toTextWithCursors(result);
const history = result.history().map(toSpanWithHistory);
result.free();
return {
...jsResult,
history,
};
}
function init() {
if (isInitialised) {
return;
}
const wasmBinary = Uint8Array.from(atob(wasmBytes as unknown as string), (c) =>
c.charCodeAt(0)
);
initSync({ module: wasmBinary });
isInitialised = true;
}
function toWasmTextWithCursors(
text: string | TextWithOptionalCursors
): wasmTextWithCursors {
const isInputString = typeof text === 'string';
const leftText = isInputString ? text : text.text;
const leftCursors = isInputString ? [] : (text.cursors ?? []);
return new wasmTextWithCursors(leftText, leftCursors.map(toWasmCursorPosition));
}
function toWasmCursorPosition({ id, position }: CursorPosition): wasmCursorPosition {
return new wasmCursorPosition(id, position);
}
function toTextWithCursors(textWithCursor: wasmTextWithCursors): TextWithCursors {
const wasmCursors = textWithCursor.cursors();
const cursors = wasmCursors.map(toCursorPosition);
for (const cursor of wasmCursors) {
cursor.free();
}
return {
text: textWithCursor.text(),
cursors,
};
}
function toCursorPosition(cursor: wasmCursorPosition): CursorPosition {
return {
id: cursor.id(),
position: cursor.characterIndex(),
};
}
function toSpanWithHistory(span: wasmSpanWithHistory): SpanWithHistory {
const result = {
text: span.text(),
history: span.history(),
};
span.free();
return result;
}

View file

@ -2,7 +2,6 @@ const path = require('path');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const common = { const common = {
entry: './src/index.ts',
optimization: { optimization: {
// the consuming project should take care of minification // the consuming project should take care of minification
minimize: false, minimize: false,
@ -38,8 +37,10 @@ const common = {
}; };
module.exports = [ module.exports = [
// Web build: real WebAssembly, instantiated synchronously from inlined base64.
merge(common, { merge(common, {
target: 'web', target: 'web',
entry: './src/index.ts',
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'reconcile.web.js', filename: 'reconcile.web.js',
@ -50,12 +51,31 @@ module.exports = [
globalObject: 'this', globalObject: 'this',
}, },
}), }),
// Node build: real WebAssembly.
merge(common, { merge(common, {
target: 'node', target: 'node',
entry: './src/index.ts',
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'reconcile.node.js', filename: 'reconcile.node.js',
libraryTarget: 'commonjs2', libraryTarget: 'commonjs2',
}, },
}), }),
// React Native build: wasm2js (pure JS), for Hermes which has no
// `WebAssembly` global. Sources come from `pkg-rn/`
merge(common, {
target: 'web',
entry: './src/index.rn.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'reconcile.rn.js',
library: {
name: 'reconcile',
type: 'umd',
},
globalObject: 'this',
},
}),
]; ];