Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Besides preventing jsdiff from emitting patches that jsdiff itself cannot parse, other compatibility impacts should be fairly limited - essentially only occurring when one generates patches using `includeIndex: true` but `includeFileHeaders: false` (since virtually every tool that works with patches will prefer to use the `---`/`+++` file headers to determine the filenames of files to patch, rather than the `Index:` header. To the extent that this change affects compatibility at all, it should mostly be beneficial; GNU `patch` attempts to unquote quoted index headers if it sees them, so this change makes jsdiff's output more compatible with GNU `patch`.
- [#707](https://github.com/kpdecker/jsdiff/pull/707) **objects with `toJSON` methods that return arrays are supported properly in `diffJson` now**. (Previously, these would be serialized as objects with numeric keys instead of arrays.)
- [#712](https://github.com/kpdecker/jsdiff/pull/712) **Big performance improvement.** The core diffing loop indexed its array of furthest-reaching paths by diagonal number, and half of the diagonal numbers are negative. JavaScript engines store negative array indices as ordinary named properties rather than as array elements, so every lookup in the innermost loop was a slow dictionary access. The indices are now shifted to be non-negative. Results are unchanged; most non-trivial diffs now seem to be about 4x faster.
- [#710](https://github.com/kpdecker/jsdiff/pull/710) **async mode is now nearly as fast as sync mode**. Async mode works by breaking the diff computation into small steps, with each step enqueuing the next; this enqueuing was originally done using `setTimeout`. However, nested `setTimeout` timeouts are "clamped" to a minimum delay of 4ms (see e.g. the "Timers" section of the HTML spec, as well as https://stackoverflow.com/a/9647221/1709587, https://stackoverflow.com/q/18826570/1709587, and https://www.belshe.com/2010/06/04/chrome-cranking-up-the-clock/ for some relevant discussion). This meant async mode on a long diff was spending more time waiting for the next step to begin running than it was spending actually computing the diff. This is now fixed and async mode is almost as fast as sync mode.

## 9.0.0

Expand Down
7 changes: 4 additions & 3 deletions src/diff/base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {ChangeObject, AllDiffOptions, AbortableDiffOptions, DiffCallbackNonabortable, CallbackOptionAbortable, CallbackOptionNonabortable, DiffCallbackAbortable, TimeoutOption, MaxEditLengthOption} from '../types.js';
import {enqueueTask} from '../util/yield.js';

/**
* Like a ChangeObject, but with no value and an extra `previousComponent` property.
Expand Down Expand Up @@ -83,7 +84,7 @@ export default class Diff<
const done = (value: ChangeObject<ValueT>[]) => {
value = this.postProcess(value, options);
if (callback) {
setTimeout(function() { callback(value); }, 0);
enqueueTask(function() { callback(value); });
return undefined;
} else {
return value;
Expand Down Expand Up @@ -210,15 +211,15 @@ export default class Diff<
// in which case it will return undefined.
if (callback) {
(function exec() {
setTimeout(function() {
enqueueTask(function() {
if (editLength > maxEditLength || Date.now() > abortAfterTimestamp) {
return (callback as DiffCallbackAbortable<ValueT>)(undefined);
}

if (!execEditLength()) {
exec();
}
}, 0);
});
}());
} else {
while (editLength <= maxEditLength && Date.now() <= abortAfterTimestamp) {
Expand Down
26 changes: 26 additions & 0 deletions src/util/yield.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
let enqueueTaskCalls = 0;

// Adds `callback` to the event queue as a macrotask, much like setTimeout(callback, 0) would.
// Unlike setTimeout(callback, 0), this MessageChannel-based approach isn't delayed by timer
// clamping (at least 4ms per nested timer in browsers, 1ms per timer in Node). We want to avoid
// timer clamping because it would make jsdiff's async mode much much slower than sync mode for
// no good reason.
export function enqueueTask(callback: () => void): void {
// The occasional setTimeout calls are a compromise to mitigate
// https://bugs.webkit.org/show_bug.cgi?id=327531.
// Without this workaround, async mode would be effectively sync inside a web worker in Safari.
// Instead, we very occasionally yield just so that really long-running async diffs will
// occasionally yield in Safari, while still eliminating 90% of the clamping in all environments.
if (++enqueueTaskCalls % 10 == 0) {
setTimeout(callback, 0);
return;
}

const {port1, port2} = new MessageChannel();
port1.onmessage = () => {
// An open port would keep a Node process alive
port1.close();
callback();
};
port2.postMessage(undefined);
}
27 changes: 27 additions & 0 deletions test/diff/character.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,37 @@ describe('diff/character', function() {
diffChars('whatever', 'whatever', {ignoreCase: true});
diffChars('whatever', 'whatever', {ignoreCase: true, callback: () => {}});
});
});

describe('async mode', function() {
it('should return undefined when called in async mode', function() {
expect(diffChars('whatever', 'whatever', {callback: () => {}})).to.be.undefined;
expect(diffChars('whatever', 'whatever else', {callback: () => {}})).to.be.undefined;
});

it('should not wait for a timer between iterations in async mode', function(done) {
// 1000 insertions take 1000 iterations. Waiting for setTimeout between them used to take
// at least 1s in Node and 4s in browsers, due to timer clamping.
const start = Date.now();
diffChars('', 'a'.repeat(1000), function(diffResult) {
expect(diffResult).to.have.length(1);
expect(Date.now() - start).to.be.below(500);
done();
});
});

it('does not starve timers while it runs', function(done) {
// Confirm that we're actually yielding control back to the event loop in a way that allows
// other stuff to run - that is after all the point of async mode!
const oldText = 'ax'.repeat(1000);
const newText = 'ay'.repeat(1000);
let ticks = 0;
const interval = setInterval(() => { ticks++; }, 1);
diffChars(oldText, newText, {callback: () => {
clearInterval(interval);
expect(ticks).to.be.at.least(100);
done();
}});
});
Comment on lines +106 to +118

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anzal1, from the description and comments in your PR, I expected this to fail in Node - but it does not, at least on my machine. Does it fail on yours? I haven't tested in browsers yet but at least in Node I'm simply not seeing the starvation behaviour in the first place that your PR did extra work to avoid.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, passes for me too. That test was written for an older version of #715 that reused one channel, and that does starve timers in Node. A fresh channel per yield like this doesn't. My bad for the confusion.

The only place I could get this approach to starve timers was a WebKit Web Worker. But I looked through 41 repos that use jsdiff in workers and none of them use async mode there, so I wouldn't worry about it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 thanks!

(I am curious how you found and reviewed those 41 repos. Does "I looked through" here mean "Claude looked through"?)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude and I work together now :)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some further remarks:

I'll check tomorrow if I can repro the timer starvation inside a worker.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both macrotasks, but the spec lets the browser pick which task queue runs next, and timers and messages are separate queues. WebKit workers just keep picking messages. Repro if useful (open in Safari): https://gist.github.com/anzal1/cbcc4255edf861dd62cab4a8158d4115

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was about to post saying I've tested this now and can't reproduce starvation in a web worker (in either Firefox or Chrome, the browsers I've got installed here on my Ubuntu desktop)... but then realised you specifically said a WebKit web worker, something which I have no ability to test. Bah!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got Claude to assure me that you are correct, and filed a WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=327531

});
});