From a0fcf1314ac7d80bdcc1c0ef0e3ecbacf5e110bd Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 31 May 2026 16:57:03 +0100 Subject: [PATCH] Reveiw --- .gitignore | 3 + README.md | 7 + reconcile-js/package-lock.json | 27 +++ reconcile-js/package.json | 4 +- reconcile-js/src/index.rn.test.ts | 95 ++++++++ reconcile-js/src/index.rn.ts | 47 ++++ reconcile-js/src/index.ts | 364 +++--------------------------- reconcile-js/webpack.config.js | 22 +- 8 files changed, 234 insertions(+), 335 deletions(-) create mode 100644 reconcile-js/src/index.rn.test.ts create mode 100644 reconcile-js/src/index.rn.ts diff --git a/.gitignore b/.gitignore index 0957a69..c58feaf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ node_modules # WebPack build output dist +# Generated wasm-bindgen bundler + wasm2js output for the React Native build +pkg-rn + # Python virtual environment .venv diff --git a/README.md b/README.md index c25ef92..8992aad 100644 --- a/README.md +++ b/README.md @@ -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). +#### 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 [`wasm2js`](https://github.com/WebAssembly/binaryen) (pure-JavaScript) +build via its `react-native` entry point. + ### Python Install via uv or pip: diff --git a/reconcile-js/package-lock.json b/reconcile-js/package-lock.json index 18ad46b..78ad153 100644 --- a/reconcile-js/package-lock.json +++ b/reconcile-js/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@types/jest": "^30.0.0", + "binaryen": "^123.0.0", "jest": "^30.3.0", "prettier": "^3.8.1", "reconcile-text": "file:../pkg", @@ -65,6 +66,7 @@ "version": "7.28.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1656,6 +1658,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1682,6 +1685,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1908,6 +1912,24 @@ "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": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1950,6 +1972,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3053,6 +3076,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -4936,6 +4960,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5072,6 +5097,7 @@ "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -5119,6 +5145,7 @@ "version": "6.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/reconcile-js/package.json b/reconcile-js/package.json index 42b92df..69a333f 100644 --- a/reconcile-js/package.json +++ b/reconcile-js/package.json @@ -4,6 +4,7 @@ "description": "Intelligent 3-way text merging with automated conflict resolution", "main": "dist/reconcile.node.js", "browser": "dist/reconcile.web.js", + "react-native": "dist/reconcile.rn.js", "keywords": [ "text editing", "sync", @@ -31,12 +32,13 @@ "dist/**/*" ], "scripts": { - "build": "webpack --mode production", + "build": "node scripts/build-rn.mjs && webpack --mode production", "format": "prettier --write \"./**/*.(ts|scss|json|html)\"", "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest" }, "devDependencies": { "@types/jest": "^30.0.0", + "binaryen": "^123.0.0", "jest": "^30.3.0", "prettier": "^3.8.1", "reconcile-text": "file:../pkg", diff --git a/reconcile-js/src/index.rn.test.ts b/reconcile-js/src/index.rn.test.ts new file mode 100644 index 0000000..aea7831 --- /dev/null +++ b/reconcile-js/src/index.rn.test.ts @@ -0,0 +1,95 @@ +import { reconcile, reconcileWithHistory, diff, undiff } from './index.rn'; +import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +installWasmLeakDetector(); + +afterEach(() => { + const leaks = checkForWasmLeaks(); + if (leaks.length > 0) { + throw new Error( + `WASM memory leak: ${leaks.length} object(s) not freed:\n ${leaks.join('\n ')}` + ); + } +}); + +describe('reconcile (wasm2js / React Native build)', () => { + it('call reconcile without cursors', () => { + expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world'); + }); + + it('call reconcile with cursors', () => { + const result = reconcile( + 'Hello', + { + text: 'Hello world', + cursors: [ + { + id: 3, + position: 2, + }, + ], + }, + { + text: 'Hi world', + cursors: [ + { + id: 4, + position: 0, + }, + { id: 5, position: 3 }, + ], + } + ); + + expect(result.text).toEqual('Hi world'); + expect(result.cursors).toEqual([ + { id: 3, position: 0 }, + { id: 4, position: 0 }, + { id: 5, position: 3 }, + ]); + }); + + it('call reconcileWithHistory', () => { + const result = reconcileWithHistory('Hello', 'Hello world', 'Hi world'); + + expect(result.text).toEqual('Hi world'); + expect(result.history.length).toBeGreaterThan(0); + }); +}); + +describe('test_diff_and_undiff_are_inverse (wasm2js / React Native build)', () => { + const resourcesPath = path.join(__dirname, '../../tests/resources'); + + const readFileSlice = (fileName: string, start: number, end: number): string => { + const filePath = path.join(resourcesPath, fileName); + const content = fs.readFileSync(filePath, 'utf-8'); + const chars = Array.from(content); // Handle unicode properly + return chars.slice(start, Math.min(end, chars.length)).join(''); + }; + + const files = ['pride_and_prejudice.txt', 'room_with_a_view.txt', 'blns.txt']; + + const ranges = [{ start: 0, end: 50000 }]; + + files.forEach((file1) => { + files.forEach((file2) => { + ranges.forEach((range1) => { + ranges.forEach((range2) => { + it(`should diff & undiff ${file1}[${range1.start}..${range1.end}], ${file2}[${range2.start}..${range2.end}] without panic`, () => { + const content1 = readFileSlice(file1, range1.start, range1.end); + const content2 = readFileSlice(file2, range2.start, range2.end); + + const changes = diff(content1, content2); + const actual = undiff(content1, changes); + expect(actual).toEqual(content2); + }); + }); + }); + }); + }); +}); diff --git a/reconcile-js/src/index.rn.ts b/reconcile-js/src/index.rn.ts new file mode 100644 index 0000000..1487a59 --- /dev/null +++ b/reconcile-js/src/index.rn.ts @@ -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'; diff --git a/reconcile-js/src/index.ts b/reconcile-js/src/index.ts index d00051c..7371169 100644 --- a/reconcile-js/src/index.ts +++ b/reconcile-js/src/index.ts @@ -1,8 +1,7 @@ import { CursorPosition as wasmCursorPosition, - reconcile as wasmReconcile, TextWithCursors as wasmTextWithCursors, - SpanWithHistory as wasmSpanWithHistory, + reconcile as wasmReconcile, reconcileWithHistory as wasmReconcileWithHistory, diff as wasmDiff, undiff as wasmUndiff, @@ -11,341 +10,40 @@ import { import wasmBytes from 'reconcile-text/reconcile_text_bg.wasm'; -// Define the enum values as const arrays to avoid duplication -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`; +import { makeReconcileApi, type WasmBackend } from './core'; let isInitialised = false; -/** - * 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), 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(); +const backend: WasmBackend = { + CursorPosition: wasmCursorPosition, + TextWithCursors: wasmTextWithCursors, + reconcile: wasmReconcile, + reconcileWithHistory: wasmReconcileWithHistory, + diff: wasmDiff, + undiff: wasmUndiff, + ensureReady() { + if (isInitialised) { + return; + } - if (!BUILTIN_TOKENIZERS.includes(tokenizer)) { - throw new Error(UNSUPPORTED_TOKENIZER_ERROR); - } + const wasmBinary = Uint8Array.from(atob(wasmBytes as unknown as string), (c) => + c.charCodeAt(0) + ); + initSync({ module: wasmBinary }); - const leftCursor = toWasmTextWithCursors(left); - const rightCursor = toWasmTextWithCursors(right); + isInitialised = true; + }, +}; - const result = wasmReconcile(original, leftCursor, rightCursor, tokenizer); +export const { reconcile, diff, undiff, reconcileWithHistory } = + makeReconcileApi(backend); - leftCursor.free(); - rightCursor.free(); - - const jsResult = toTextWithCursors(result); - result.free(); - - return jsResult; -} - -/** - * 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 { - 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, - 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; -} +export type { + BuiltinTokenizer, + History, + CursorPosition, + TextWithCursors, + TextWithOptionalCursors, + TextWithCursorsAndHistory, + SpanWithHistory, +} from './core'; diff --git a/reconcile-js/webpack.config.js b/reconcile-js/webpack.config.js index bf126fa..280bc52 100644 --- a/reconcile-js/webpack.config.js +++ b/reconcile-js/webpack.config.js @@ -2,7 +2,6 @@ const path = require('path'); const { merge } = require('webpack-merge'); const common = { - entry: './src/index.ts', optimization: { // the consuming project should take care of minification minimize: false, @@ -38,8 +37,10 @@ const common = { }; module.exports = [ + // Web build: real WebAssembly, instantiated synchronously from inlined base64. merge(common, { target: 'web', + entry: './src/index.ts', output: { path: path.resolve(__dirname, 'dist'), filename: 'reconcile.web.js', @@ -50,12 +51,31 @@ module.exports = [ globalObject: 'this', }, }), + + // Node build: real WebAssembly. merge(common, { target: 'node', + entry: './src/index.ts', output: { path: path.resolve(__dirname, 'dist'), filename: 'reconcile.node.js', 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', + }, + }), ];