Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Handle deferred processing when document is hidden #402

Merged
merged 26 commits into from
Oct 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
96213a4
Revert back to `requestAnimationFrame`/`setImmediate`
afshin Sep 9, 2022
faad187
Add schedule/unschedule to coreutils
afshin Sep 11, 2022
7396ad7
Use schedule/unschedule from coreutils
afshin Sep 11, 2022
08c9577
Use schedule/unschedule from coreutils in Poll
afshin Sep 11, 2022
a6bdaae
Add support for hidden poll detection and lingering, add tests
afshin Sep 11, 2022
9f50a16
Update API and fix IIFE example
afshin Sep 11, 2022
28a0607
Use named type instead of ReturnType<typeof...>
afshin Sep 11, 2022
a85cacb
Switch back to getter/setter for standby
afshin Sep 11, 2022
32b3f9e
Add explication comment for ScheduleHandle type
afshin Sep 12, 2022
856f917
Tweak schedule test
afshin Sep 12, 2022
03494c8
Add `background` flag to `schedule` and `unschedule`
afshin Sep 20, 2022
0af044f
Add `background` support for `MessageLoop`
afshin Sep 20, 2022
ea7105b
Always allow polling to occur in the background
afshin Sep 20, 2022
5e88995
Update API
afshin Sep 20, 2022
a80ddbc
Update to `setBackground` and `getBackground`
afshin Sep 20, 2022
f9fbdac
Handle background message loop processing internally without exposing…
afshin Sep 22, 2022
6a11f9f
Cache hidden flag instead of checking the DOM every tick
afshin Sep 22, 2022
fdb7114
Add explanatory comment
afshin Sep 22, 2022
122025c
Update API
afshin Sep 22, 2022
1fb47e0
Remove schedule/unschedule from coreutils
afshin Sep 27, 2022
aa63e0b
Use a private schedule function that is promise-backed for the messag…
afshin Sep 27, 2022
7ccb59b
Switch back to setTimeout for polls, but keep the protected hidden flag
afshin Sep 27, 2022
871b771
Update API
afshin Sep 27, 2022
b29e1a0
Remove superfluous import from iife example, update packages and tsco…
afshin Sep 27, 2022
a43acf4
Change names back
afshin Sep 27, 2022
0af20de
Update packages/polling/src/poll.ts
afshin Sep 30, 2022
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
64 changes: 32 additions & 32 deletions packages/messaging/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,29 @@ export type MessageHook =
* The namespace for the global singleton message loop.
*/
export namespace MessageLoop {
/**
* A function that cancels the pending loop task; `null` if unavailable.
*/
let pending: (() => void) | null = null;

/**
* Schedules a function for invocation as soon as possible asynchronously.
*
* @param fn The function to invoke when called back.
*
* @returns An anonymous function that will unschedule invocation if possible.
*/
const schedule = (
resolved =>
(fn: () => unknown): (() => void) => {
let rejected = false;
resolved.then(() => !rejected && fn());
return () => {
rejected = true;
};
}
)(Promise.resolve());
Copy link
Member

Choose a reason for hiding this comment

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

As mentioned in last weeks lab dev meeting, using a Promise or async will schedule the task as a microtask, compared to requestAnimationFrame / setTimeout etc that will schedule it as a macro task. This difference is likely to be significant (@afshin has the details).

Copy link
Member Author

Choose a reason for hiding this comment

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

It is safe in this circumstance because message propagation is both lightweight and synchronous. This is the reason why the same technique is unavailable to us in Poll.

Copy link
Member Author

Choose a reason for hiding this comment

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

@afshin should we move forward with this one?

Yes, I wanted to give @vidartf a chance to look, but I'm happy to move ahead.


/**
* Send a message to a message handler to process immediately.
*
Expand Down Expand Up @@ -296,7 +319,7 @@ export namespace MessageLoop {
handler: IMessageHandler,
hook: MessageHook
): void {
// Lookup the hooks for the handler.
// Look up the hooks for the handler.
let hooks = messageHooks.get(handler);

// Bail early if the hook is already installed.
Expand Down Expand Up @@ -378,8 +401,7 @@ export namespace MessageLoop {
* Process the pending posted messages in the queue immediately.
*
* #### Notes
* This function is useful when posted messages must be processed
* immediately, instead of on the next animation frame.
* This function is useful when posted messages must be processed immediately.
*
* This function should normally not be needed, but it may be
* required to work around certain browser idiosyncrasies.
Expand All @@ -388,12 +410,13 @@ export namespace MessageLoop {
*/
export function flush(): void {
// Bail if recursion is detected or if there is no pending task.
if (flushGuard || loopTaskID === 0) {
if (flushGuard || pending === null) {
return;
}

// Unschedule the pending loop task.
unschedule(loopTaskID);
pending();
pending = null;

// Run the message loop within the recursion guard.
flushGuard = true;
Expand Down Expand Up @@ -467,34 +490,11 @@ export namespace MessageLoop {
console.error(err);
};

type ScheduleHandle = number | any; // requestAnimationFrame (number) and setImmediate (any)

/**
* The id of the pending loop task animation frame.
*/
let loopTaskID: ScheduleHandle = 0;

/**
* A guard flag to prevent flush recursion.
*/
let flushGuard = false;

/**
* A function to schedule an event loop callback.
*/
const schedule = ((): ScheduleHandle => {
let ok = typeof requestAnimationFrame === 'function';
return ok ? requestAnimationFrame : setImmediate;
})();

/**
* A function to unschedule an event loop callback.
*/
const unschedule = (() => {
let ok = typeof cancelAnimationFrame === 'function';
return ok ? cancelAnimationFrame : clearImmediate;
})();

/**
* Invoke a message hook with the specified handler and message.
*
Expand Down Expand Up @@ -543,12 +543,12 @@ export namespace MessageLoop {
messageQueue.addLast({ handler, msg });

// Bail if a loop task is already pending.
if (loopTaskID !== 0) {
if (pending !== null) {
return;
}

// Schedule a run of the message loop.
loopTaskID = schedule(runMessageLoop);
pending = schedule(runMessageLoop);
}

/**
Expand All @@ -559,8 +559,8 @@ export namespace MessageLoop {
* be processed on the next cycle of the loop.
*/
function runMessageLoop(): void {
// Clear the task ID so the next loop can be scheduled.
loopTaskID = 0;
// Clear the task so the next loop can be scheduled.
pending = null;

// If the message queue is empty, there is nothing else to do.
if (messageQueue.isEmpty) {
Expand Down
17 changes: 15 additions & 2 deletions packages/messaging/tests/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ describe('@lumino/messaging', () => {

describe('flush()', () => {
it('should immediately process all posted messages', () => {
const expected = ['one', 'two', 'three', 'six', 'four', 'five'];
let h1 = new Handler();
let h2 = new Handler();
MessageLoop.postMessage(h1, new Message('one'));
Expand All @@ -480,9 +481,21 @@ describe('@lumino/messaging', () => {
MessageLoop.postMessage(h2, new Message('two'));
MessageLoop.postMessage(h1, new Message('three'));
MessageLoop.postMessage(h2, new Message('three'));

MessageLoop.flush();
expect(h1.messages).to.deep.equal(['one', 'two', 'three']);
expect(h2.messages).to.deep.equal(['one', 'two', 'three']);

MessageLoop.postMessage(h1, new Message('four'));
MessageLoop.postMessage(h2, new Message('four'));
MessageLoop.postMessage(h1, new Message('five'));
MessageLoop.postMessage(h2, new Message('five'));

MessageLoop.sendMessage(h1, new Message('six'));
MessageLoop.sendMessage(h2, new Message('six'));

MessageLoop.flush();

expect(h1.messages).to.deep.equal(expected);
expect(h2.messages).to.deep.equal(expected);
});

it('should ignore recursive calls', () => {
Expand Down
105 changes: 80 additions & 25 deletions packages/polling/src/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class Poll<T = any, U = any, V extends string = 'standby'>
*/
constructor(options: Poll.IOptions<T, U, V>) {
this._factory = options.factory;
this._linger = options.linger ?? Private.DEFAULT_LINGER;
this._standby = options.standby || Private.DEFAULT_STANDBY;
this._state = { ...Private.DEFAULT_STATE, timestamp: new Date().getTime() };

Expand Down Expand Up @@ -244,6 +245,8 @@ export class Poll<T = any, U = any, V extends string = 'standby'>

this._execute();
};

// Cache the handle in case it needs to be unscheduled.
this._timeout = setTimeout(execute, state.interval);
}

Expand Down Expand Up @@ -274,18 +277,34 @@ export class Poll<T = any, U = any, V extends string = 'standby'>
});
}

/**
* Whether the poll is hidden.
*
* #### Notes
* This property is only relevant in a browser context.
*/
protected get hidden(): boolean {
return Private.hidden;
}

/**
* Execute a new poll factory promise or stand by if necessary.
*/
private _execute(): void {
let standby =
typeof this.standby === 'function' ? this.standby() : this.standby;
standby =
standby === 'never'
? false
: standby === 'when-hidden'
? !!(typeof document !== 'undefined' && document && document.hidden)
: standby;

// Check if execution should proceed, linger, or stand by.
if (standby === 'never') {
standby = false;
} else if (standby === 'when-hidden') {
if (this.hidden) {
standby = ++this._lingered > this._linger;
} else {
this._lingered = 0;
standby = false;
}
}

// If in standby mode schedule next tick without calling the factory.
if (standby) {
Expand Down Expand Up @@ -322,11 +341,13 @@ export class Poll<T = any, U = any, V extends string = 'standby'>
private _disposed = new Signal<this, void>(this);
private _factory: Poll.Factory<T, U, V>;
private _frequency: IPoll.Frequency;
private _linger: number;
private _lingered = 0;
private _standby: Poll.Standby | (() => boolean | Poll.Standby);
private _state: IPoll.State<T, U, V>;
private _tick = new PromiseDelegate<this>();
private _ticked = new Signal<this, IPoll.State<T, U, V>>(this);
private _timeout?: ReturnType<typeof setTimeout>; // Support node and browser.
private _timeout: ReturnType<typeof setTimeout> | undefined;
}

/**
Expand Down Expand Up @@ -371,6 +392,12 @@ export namespace Poll {
*/
factory: Factory<T, U, V>;

/**
* The number of ticks to linger if poll switches to standby `when-hidden`.
* Defaults to `1`.
*/
linger?: number;

/**
* The polling frequency parameters.
*/
Expand All @@ -395,7 +422,7 @@ export namespace Poll {
standby?: Standby | (() => boolean | Standby);
}
/**
* An interval value (0ms) that indicates the poll should tick immediately.
* An interval value in ms that indicates the poll should tick immediately.
*/
export const IMMEDIATE = 0;

Expand Down Expand Up @@ -431,6 +458,11 @@ namespace Private {
max: 30 * 1000
};

/**
* The default number of times to `linger` when a poll is hidden.
*/
export const DEFAULT_LINGER = 1;

/**
* The default poll name.
*/
Expand Down Expand Up @@ -461,23 +493,6 @@ namespace Private {
timestamp: new Date(0).getTime()
};

/**
* Get a random integer between min and max, inclusive of both.
*
* #### Notes
* From
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random#Getting_a_random_integer_between_two_values_inclusive
*
* From the MDN page: It might be tempting to use Math.round() to accomplish
* that, but doing so would cause your random numbers to follow a non-uniform
* distribution, which may not be acceptable for your needs.
*/
function getRandomIntInclusive(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}

/**
* Returns the number of milliseconds to sleep before the next tick.
*
Expand All @@ -500,4 +515,44 @@ namespace Private {

return Math.min(max, random);
}

/**
* Keep track of whether the document is hidden. This flag is only relevant in
* a browser context.
*
* Listen to `visibilitychange` event to set the `hidden` flag.
*
* Listening to `pagehide` is also necessary because Safari support for
* `visibilitychange` events is partial, cf.
* https://developer.mozilla.org/docs/Web/API/Document/visibilitychange_event
*/
export let hidden = (() => {
if (typeof document === 'undefined') {
return false;
}
document.addEventListener('visibilitychange', () => {
hidden = document.visibilityState === 'hidden';
});
document.addEventListener('pagehide', () => {
hidden = document.visibilityState === 'hidden';
});
return document.visibilityState === 'hidden';
})();

/**
* Get a random integer between min and max, inclusive of both.
*
* #### Notes
* From
* https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Math/random#Getting_a_random_integer_between_two_values_inclusive
*
* From the MDN page: It might be tempting to use Math.round() to accomplish
* that, but doing so would cause your random numbers to follow a non-uniform
* distribution, which may not be acceptable for your needs.
*/
function getRandomIntInclusive(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
}
Loading