Update timeout API to use new status code
Rather than letting the timeout error escape the public API, we'll
catch it and return a new GenerateFragmentStatus code. This is in
response to feedback that having both status codes and errors led
to messier integrations.
diff --git a/package.json b/package.json
index 42566fa..9717638 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "text-fragments-polyfill",
- "version": "3.0.0",
+ "version": "3.1.0",
"description": "This is a polyfill for the [Text Fragments](https://wicg.github.io/scroll-to-text-fragment/) feature for browsers that don't support it natively.",
"main": "./dist/text-fragments.js",
"browser": "./dist/text-fragments.js",
diff --git a/src/fragment-generation-utils.js b/src/fragment-generation-utils.js
index 57770c5..7c0eff2 100644
--- a/src/fragment-generation-utils.js
+++ b/src/fragment-generation-utils.js
@@ -23,32 +23,16 @@
// Desired max run time, in ms. Can be overwritten.
let timeoutDurationMs = 500;
-
let t0; // Start timestamp for fragment generation
-const checkTimeout = () => {
- const delta = Date.now() - t0;
- if (delta > timeoutDurationMs) {
- const timeoutError =
- new Error(`Fragment generation timed out after ${delta} ms.`);
- timeoutError.isTimeout = true;
- throw timeoutError;
- }
-};
-
-const recordStartTime = (newStartTime) => {
- t0 = newStartTime;
-};
-
/**
* Allows overriding the max runtime to specify a different interval. Fragment
* generation will halt and throw an error after this amount of time.
* @param {Number} newTimeoutDurationMs - the desired timeout length, in ms.
*/
-export const setTimeout =
- (newTimeoutDurationMs) => {
- timeoutDurationMs = newTimeoutDurationMs;
- }
+export const setTimeout = (newTimeoutDurationMs) => {
+ timeoutDurationMs = newTimeoutDurationMs;
+};
/**
* Enum indicating the success, or failure reason, of generateFragment.
@@ -56,8 +40,9 @@
export const GenerateFragmentStatus = {
SUCCESS: 0, // A fragment was generated.
INVALID_SELECTION: 1, // The selection provided could not be used.
- AMBIGUOUS: 2 // No unique fragment could be identified for this selection.
-}
+ AMBIGUOUS: 2, // No unique fragment could be identified for this selection.
+ TIMEOUT: 3 // Computation could not complete in time.
+};
/**
* @typedef {Object} GenerateFragmentResult
@@ -73,10 +58,83 @@
* @param {Date} [startTime] - the time when generation began, for timeout
* purposes. Defaults to current timestamp.
* @return {GenerateFragmentResult}
+ */
+export const generateFragment = (selection, startTime = Date.now()) => {
+ try {
+ return doGenerateFragment(selection, startTime);
+ } catch (err) {
+ return {status: GenerateFragmentStatus.TIMEOUT};
+ }
+};
+
+/**
+ * Checks whether fragment generation can be attempted for a given range. This
+ * checks a handful of simple conditions: the range must be nonempty, not inside
+ * an <input>, etc. A true return is not a guarantee that fragment generation
+ * will succeed; instead, this is a way to quickly rule out generation in cases
+ * where a failure is predictable.
+ * @param {Range} range
+ * @return {boolean} - true if fragment generation may proceed; false otherwise.
+ */
+export const isValidRangeForFragmentGeneration = (range) => {
+ // Check that the range isn't just punctuation and whitespace. Only check the
+ // first |TRUNCATE_RANGE_CHECK_CHARS| to put an upper bound on runtime; ranges
+ // that start with (e.g.) thousands of periods should be rare.
+ // This also implicitly ensures the selection isn't in an input or textarea
+ // field, as document.selection contains an empty range in these cases.
+ if (!range.toString()
+ .substring(0, TRUNCATE_RANGE_CHECK_CHARS)
+ .match(fragments.internal.NON_BOUNDARY_CHARS)) {
+ return false;
+ }
+
+ // Check for iframe
+ try {
+ if (range.startContainer.ownerDocument.defaultView !== window.top) {
+ return false;
+ }
+ } catch {
+ // If accessing window.top throws an error, this is in a cross-origin
+ // iframe.
+ return false;
+ }
+
+ // Walk up the DOM to ensure that the range isn't inside an editable. Limit
+ // the search depth to |MAX_DEPTH| to constrain runtime.
+ let node = range.commonAncestorContainer;
+ let numIterations = 0;
+ while (node) {
+ if (node.nodeType == Node.ELEMENT_NODE) {
+ if (['TEXTAREA', 'INPUT'].includes(node.tagName)) {
+ return false;
+ }
+
+ const editable = node.attributes.getNamedItem('contenteditable');
+ if (editable && editable.value !== 'false') {
+ return false;
+ }
+
+ // Cap the number of iterations at |MAX_PRECONDITION_DEPTH| to put an
+ // upper bound on runtime.
+ numIterations++;
+ if (numIterations >= MAX_DEPTH) {
+ return false;
+ }
+ }
+ node = node.parentNode;
+ }
+
+ return true;
+};
+
+/* eslint-disable valid-jsdoc */
+/**
+ * @see {@link generateFragment} - this method wraps the error-throwing portions
+ * of that method.
* @throws {Error} - Will throw if computation takes longer than the accepted
* timeout length.
*/
-export const generateFragment = (selection, startTime = Date.now()) => {
+const doGenerateFragment = (selection, startTime) => {
recordStartTime(startTime);
let range;
@@ -160,63 +218,26 @@
};
/**
- * Checks whether fragment generation can be attempted for a given range. This
- * checks a handful of simple conditions: the range must be nonempty, not inside
- * an <input>, etc. A true return is not a guarantee that fragment generation
- * will succeed; instead, this is a way to quickly rule out generation in cases
- * where a failure is predictable.
- * @param {Range} range
- * @return {boolean} - true if fragment generation may proceed; false otherwise.
+ * @throws {Error} - if the timeout duration has been exceeded, an error will
+ * be thrown so that execution can be halted.
*/
-export const isValidRangeForFragmentGeneration = (range) => {
- // Check that the range isn't just punctuation and whitespace. Only check the
- // first |TRUNCATE_RANGE_CHECK_CHARS| to put an upper bound on runtime; ranges
- // that start with (e.g.) thousands of periods should be rare.
- // This also implicitly ensures the selection isn't in an input or textarea
- // field, as document.selection contains an empty range in these cases.
- if (!range.toString()
- .substring(0, TRUNCATE_RANGE_CHECK_CHARS)
- .match(fragments.internal.NON_BOUNDARY_CHARS)) {
- return false;
+const checkTimeout = () => {
+ const delta = Date.now() - t0;
+ if (delta > timeoutDurationMs) {
+ const timeoutError =
+ new Error(`Fragment generation timed out after ${delta} ms.`);
+ timeoutError.isTimeout = true;
+ throw timeoutError;
}
+};
- // Check for iframe
- try {
- if (range.startContainer.ownerDocument.defaultView !== window.top) {
- return false;
- }
- } catch {
- // If accessing window.top throws an error, this is in a cross-origin
- // iframe.
- return false;
- }
-
- // Walk up the DOM to ensure that the range isn't inside an editable. Limit
- // the search depth to |MAX_DEPTH| to constrain runtime.
- let node = range.commonAncestorContainer;
- let numIterations = 0;
- while (node) {
- if (node.nodeType == Node.ELEMENT_NODE) {
- if (['TEXTAREA', 'INPUT'].includes(node.tagName)) {
- return false;
- }
-
- const editable = node.attributes.getNamedItem('contenteditable');
- if (editable && editable.value !== 'false') {
- return false;
- }
-
- // Cap the number of iterations at |MAX_PRECONDITION_DEPTH| to put an
- // upper bound on runtime.
- numIterations++;
- if (numIterations >= MAX_DEPTH) {
- return false;
- }
- }
- node = node.parentNode;
- }
-
- return true;
+/**
+ * Call at the start of fragment generation to set the baseline for timeout
+ * checking.
+ * @param {Date} newStartTime - the timestamp when fragment generation began
+ */
+const recordStartTime = (newStartTime) => {
+ t0 = newStartTime;
};
/**
@@ -1114,6 +1135,7 @@
backwardTraverse: backwardTraverse,
containsBlockBoundary: containsBlockBoundary,
createForwardOverrideMap: createForwardOverrideMap,
+ doGenerateFragment: doGenerateFragment,
expandRangeEndToWordBound: expandRangeEndToWordBound,
expandRangeStartToWordBound: expandRangeStartToWordBound,
findWordEndBoundInTextNode: findWordEndBoundInTextNode,
diff --git a/test/fragment-generation-utils-test.js b/test/fragment-generation-utils-test.js
index a234e58..fb01dae 100644
--- a/test/fragment-generation-utils-test.js
+++ b/test/fragment-generation-utils-test.js
@@ -626,14 +626,16 @@
selection.addRange(range);
expect(function() {
- generationUtils.generateFragment(selection, Date.now() - 1000);
+ generationUtils.forTesting.doGenerateFragment(
+ selection, Date.now() - 1000);
}).toThrowMatching(function(thrown) {
return thrown.isTimeout
});
generationUtils.setTimeout(2000);
expect(function() {
- generationUtils.generateFragment(selection, Date.now() - 1000);
+ generationUtils.forTesting.doGenerateFragment(
+ selection, Date.now() - 1000);
}).not.toThrowMatching(function(thrown) {
return thrown.isTimeout
});