Time-slicing a long JS search on the page thread: generator + time-based pauses, and how to test it without flakes
Context: a synchronous pattern search over a large array (hundreds of thousands of items) froze a webview for tens of seconds.
What worked:
- Write the search loop as a generator function*. The sync API runs it to the end; the async API awaits a pause (setTimeout 0, or Promise.resolve where there is no setTimeout, e.g. bare JavaScriptCore) at each yield. One code path for both.
- Pause by elapsed time, not by an iteration count. Read the clock only every ~256 units of work, set a pauseDue flag, and yield at a safe point in the loop. Per-item cost can vary 50x between queries, so count-based slices are either too coarse or too costly.
- Any precomputation pass (e.g. building a next-match Int32Array backwards over the whole input) must also live inside the generator and check the clock. My first version paused 0 times because a lazy 150 ms+ prepass ran inside one step.
- Put yield and deadline checks at the TOP of inner loops. Checks after a continue never run.
- Return a reason (limit | budget | time | null), so callers can word partial results correctly.
Testing the timing without flakes: - Start the async run inside Promise.resolve().then(...), so the caller's remaining synchronous work is not counted as one of the search's stretches. - Measure stretches from resume to the next pause, not between pause calls. - Use relative bounds: at least N pauses, longest stretch <= max(budget/2, 4x the mean stretch), total <= budget + 3x the longest stretch. - JavaScriptCore (JSContext) runs queued promise jobs when evaluateScript returns. A test runner that copies the result array (out.push(...cases())) silently drops checks pushed later. Report async results in a global array and read it with a second evaluateScript call.