The Puzzling Undo Bug

In his post “InDesign undo history ruined by doScript?” Thomas Silkjær reports “strange issues with the InDesign history” when launching a sub-script through app.doScript():

“When I have run this script just once, the undo history of the InDesign document in general is changed. When working normally and undoing once, it will undo the last many steps and not just one step as usual.”

This bug seems directly related to UndoModes.fastEntireScript, an argument of app.doScript() that allows to quickly “undo the entire script as a single step.”

In the InDesign Scripting Forum, Fred Goldman adds the following details:

“What happens is after running a script with fastEntireScript if you do things in InDesign that both have the same 'undo name' it will undo both when you press Ctrl+Z. For example: 1) Run script that has a app.doScript() and is set to fastEntireScript. 2) Move a text frame. 3) Move another text frame. 4) Press Ctrl+Z. Both steps 2&3 will become undone.”

This bug is no longer reported in InDesign CS5. As claimed in technical discussions, a generic way to prevent any undo issue in all versions is to apply UndoModes.entireScript instead of UndoModes.fastEntireScript. But this involves a painful renunciation, since fastEntireScript makes huge processes go really faster.

About Undo Modes

Nobody in my tribe knows exactly how UndoModes operate behind the scene. The default mode is scriptRequest, which treats “each script request as a separate step.” With entireScript or fastEntireScript the whole script is regarded as a single step, so the user can globally undo all actions. For example, if your main routine creates 50 objects in the active document, you just need to press Ctrl+Z once, and this restores the layout in its initial state.

Anyway, the difference between entireScript and fastEntireScript is not clearly documented. What makes the fast mode significantly faster is probably that InDesign gives up low-level controls of the document state while the script is running. This point has been highlighted in this discussion by Gabe Harbs (In-Tools), who finally quotes Jonathan Brown:

“The undo modes feature involves a complicated interaction between the scripting architecture and the command processing architecture. In other words, it would take two Adobe developers to figure out what’s going on to a greater depth than has already been described.”

Well.

Hacking the Bug!

Let's recap. Prior to InDesign CS5, using UndoModes.fastEntireScript in app.doScript() leads to chaos in the undo history. So if you want to speed up a routine, you cannot safely write:

// OK, this will speed up myRoutine...
// but this also leads to the infamous Undo Bug!!
// ---
app.doScript(
    myRoutine,
    undefined,
    undefined,
    UndoModes.fastEntireScript
    );
 

Note that myRoutine can refer to a script File, a Function, or even a code String—including a serialized jsxbin, as demonstrated in “Binary JavaScript Embedment (CS4/CS5)”.

The not-so-official solution to this issue is to drop out fastEntireScript, which really is not exciting:

// OK, this prevents the infamous Undo Bug...
// but this significantly slows down my heavy routine:
// ---
app.doScript(
    myRoutine,
    undefined,
    undefined,
    UndoModes.entireScript
    );
 

What if you absolutely want to boost the engine with no side effect? Here is the secret, just wrap the fast mode in a safe package:

// The FAST-and-SAFE Hack:
// ---
app.doScript(
    'app.doScript(myRoutine,undefined,undefined,UndoModes.fastEntireScript);',
    undefined,
    undefined,
    UndoModes.entireScript
    );
 

That's it! According to my tests this workaround works like a charm, at least in CS4. And although we don't need it in CS5, I suppose it still behaves transparently.

The Try-Catch Case

Incidentally, in all InDesign versions, the fastEntireScript mode is somewhat allergic to error handling. According to Gabe Harbs this is in no way a bug:

“When you catch an error, InDesign rolls back to the last snapshot of the document state (to ensure the document is in a stable state). What FAST_ENTIRE_SCRIPT does is that it does not take snapshots of the document while the script is running, so the last snapshot is at the start of the script (or in some extreme cases the before you enter certain functions, but I have not figured out how that works). Therefore if you catch an error, the document state will roll back to the beginning of the script and continue from there. This undoes everything your script did until that point and very likely renders many of your variables invalid.”

Here is a simple proof of concept:

// Why you don't want to use try-catch in fast mode
// ---
 
var myRoutine = function()
{
    var tf = app.activeDocument.textFrames[0];
    tf.contents = "foo bar";
 
    // The allowOverrides property is not available
    // in the usual state of a non-master TextFrame
    // so this causes a command processing error:
    tf.allowOverrides;
};
 
var tryCatchProcess = function()
{
    try{ myRoutine(); }
    catch(_){ alert(_); }
};
 
app.doScript(
    tryCatchProcess,
    undefined,
    undefined,
    UndoModes.fastEntireScript
    );
 

When you execute the script above, you get an error message—due to the try-catch-alert block—but this is not the point. The point is that the text frame does not contain the string "foo bar" when the script ends. This shows that fastEntireScript precludes the restoration of the text frame in the state that just precedes the error. (In a regular entireScript mode we would have got "foo bar" in the frame, despite the error, because the tf.contents affectation occurs before the error.)

What is interesting too, is that the kind of the error crucially matters. If you replace tf.allowOverrides; by throw Error("This is the End of the World!"); InDesign does not cancel what we have done in the frame and we still got "foo bar"—even in fast mode. So I suppose that the script engine makes an important distinction between JavaScript/ExtendScript execution errors, which do not involve DOM objects, and errors that relate to the command processing architecture, as mentioned above by Jonathan Brown.

The conclusion is that you don't want to use fastEntireScript if the inner process has to handle command processing errors and needs to restore intermediate states—or valid specifiers—of the related components. The purpose of app.doScript() is to execute a script as single transaction (and possibly in a different language). When you use nested app.doScript(), make sure that you can safely complete each transaction.

A simple strategy is to execute the main process in entireScript mode, so you can use native error management at this level. Then, you can speed up a specific routine through fastEntireScript, provided that the underlying code gets around try-catch and restoration issues. In other words, just be sure that your boosted routine will keep all objects in a stable state.