├── .editorconfig
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .vscode
└── launch.json
├── LICENSE
├── README.md
├── demo.html
├── package-lock.json
├── package.json
├── src
├── index.d.ts
└── index.js
└── test
├── index.test.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [{package.json,.*rc,*.yml}]
11 | indent_style = space
12 | indent_size = 2
13 |
14 | [*.md]
15 | trim_trailing_whitespace = false
16 | indent_style = space
17 | indent_size = 2
18 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Build & Test
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | branches:
9 | - master
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v3
16 | - uses: actions/setup-node@v3
17 | with:
18 | node-version: 18
19 | cache: npm
20 | - run: npm ci
21 | - run: npm test
22 | - run: npm pack
23 | - uses: actions/upload-artifact@v3
24 | with:
25 | name: npm-package
26 | path: afterframe-*.tgz
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ##########################
2 | #
3 | # Project .gitignore
4 | #
5 | ##########################
6 |
7 | dist/
8 | test/*.js
9 | test-results/
10 |
11 |
12 | ##########################
13 | #
14 | # Node .gitignore
15 | #
16 | ##########################
17 |
18 | # Logs
19 | logs
20 | *.log
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # Runtime data
26 | pids
27 | *.pid
28 | *.seed
29 | *.pid.lock
30 |
31 | # Directory for instrumented libs generated by jscoverage/JSCover
32 | lib-cov
33 |
34 | # Coverage directory used by tools like istanbul
35 | coverage
36 |
37 | # nyc test coverage
38 | .nyc_output
39 |
40 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
41 | .grunt
42 |
43 | # Bower dependency directory (https://bower.io/)
44 | bower_components
45 |
46 | # node-waf configuration
47 | .lock-wscript
48 |
49 | # Compiled binary addons (https://nodejs.org/api/addons.html)
50 | build/Release
51 |
52 | # Dependency directories
53 | node_modules/
54 | jspm_packages/
55 |
56 | # TypeScript v1 declaration files
57 | typings/
58 |
59 | # Optional npm cache directory
60 | .npm
61 |
62 | # Optional eslint cache
63 | .eslintcache
64 |
65 | # Optional REPL history
66 | .node_repl_history
67 |
68 | # Output of 'npm pack'
69 | *.tgz
70 |
71 | # Yarn Integrity file
72 | .yarn-integrity
73 |
74 | # dotenv environment variables file
75 | .env
76 | .env.test
77 |
78 | # parcel-bundler cache (https://parceljs.org/)
79 | .cache
80 |
81 | # next.js build output
82 | .next
83 |
84 | # nuxt.js build output
85 | .nuxt
86 |
87 | # vuepress build output
88 | .vuepress/dist
89 |
90 | # Serverless directories
91 | .serverless/
92 |
93 | # FuseBox cache
94 | .fusebox/
95 |
96 | # DynamoDB Local files
97 | .dynamodb/
98 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Jest",
11 | "program": "${workspaceFolder}/node_modules/.bin/jest",
12 | "args": ["--runInBand"],
13 | "disableOptimisticBPs": true
14 | },
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Andre Wiggins
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # AfterFrame
6 |
7 | > Tiny function to invoke a callback after the browser renders the next frame
8 |
9 | ## Table of Contents
10 |
11 | - [AfterFrame](#afterframe)
12 | - [Table of Contents](#table-of-contents)
13 | - [Install](#install)
14 | - [Usage](#usage)
15 | - [Examples \& Demos](#examples--demos)
16 | - [API](#api)
17 | - [afterFrame](#afterframe-1)
18 | - [Parameters](#parameters)
19 | - [Prior Work](#prior-work)
20 | - [Contribute](#contribute)
21 | - [Reporting Issues](#reporting-issues)
22 | - [Submitting pull requests](#submitting-pull-requests)
23 |
24 | ## Install
25 |
26 | This project uses [node](http://nodejs.org) and [npm](https://npmjs.com). Go check them out if you don't have them locally installed.
27 |
28 | ```sh
29 | $ npm install --save afterframe
30 | + afterframe@0.0.0
31 | ```
32 |
33 | Then with a module bundler like [rollup](http://rollupjs.org/) or [webpack](https://webpack.js.org/), use as you would anything else:
34 |
35 | ```javascript
36 | // using ES6 modules
37 | import afterFrame from "afterframe";
38 |
39 | // using CommonJS modules
40 | var afterFrame = require("afterframe");
41 | ```
42 |
43 | The [UMD](https://github.com/umdjs/umd) build is also available on [unpkg](https://unpkg.com):
44 |
45 | ```html
46 |
47 | ```
48 |
49 | You can find the function on `window.afterFrame`.
50 |
51 | ## Usage
52 |
53 | > Inspired by [Nolan Lawson's blog on measuring layout](https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/)
54 |
55 | ```js
56 | import afterFrame from "afterframe";
57 |
58 | performance.mark("start");
59 |
60 | // Do some work...
61 |
62 | afterFrame(() => {
63 | performance.mark("end");
64 | });
65 | ```
66 |
67 | `afterFrame` currently relies on [`requestAnimationFrame`](https://caniuse.com/#feat=requestanimationframe) and [`MessageChannel`](https://caniuse.com/#feat=channel-messaging) so support starts at IE10 and above.
68 |
69 | ## Examples & Demos
70 |
71 | [Sample CodePen demonstrating usage of afterFrame](https://codepen.io/andrewiggins/pen/Ydvapy?editors=0010)
72 |
73 | Example function wrapping `afterFrame` in a `Promise`:
74 |
75 | ```js
76 | let promise = null;
77 | function afterFrameAsync() {
78 | if (promise === null) {
79 | promise = new Promise((resolve) =>
80 | afterFrame((time) => {
81 | promise = null;
82 | resolve(time);
83 | })
84 | );
85 | }
86 |
87 | return promise;
88 | }
89 | ```
90 |
91 | ## API
92 |
93 | ### afterFrame
94 |
95 | Invoke the given callback after the browser renders the next frame
96 |
97 | #### Parameters
98 |
99 | - `callback` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** The function to invoke after the browser renders the next frame. The callback function is passed one argument, a [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp) similar to the one returned by [`performance.now()`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now), indicating the point in time when `afterFrame()` starts to execute callback functions.
100 |
101 | ## Prior Work
102 |
103 | - The implementation for this package is heavily inspired by [React's Scheduler](https://github.com/facebook/react/blob/master/packages/scheduler/src/Scheduler.js). Some commits of particular interest:
104 | - [Post to MessageChannel instead of window ](https://github.com/facebook/react/pull/14234)
105 | - [Remove window.postMessage fallback](https://git.io/fhsQk)
106 | - [Reduce scheduler serialization overhead](https://github.com/facebook/react/pull/14249)
107 | - [Jason Miller's tweet](https://twitter.com/_developit/status/1081681351122829325) of the same function provided some good inspiration for reducing code size
108 | - [Nolan Lawson blogged](https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/) about using a similar technique to more accurately measure layout time
109 |
110 | ## Contribute
111 |
112 | First off, thanks for taking the time to contribute!
113 | Now, take a moment to be sure your contributions make sense to everyone else.
114 |
115 | ### Reporting Issues
116 |
117 | Found a problem? Want a new feature? First of all see if your issue or idea has [already been reported](../../issues).
118 | If don't, just open a [new clear and descriptive issue](../../issues/new).
119 |
120 | ### Submitting pull requests
121 |
122 | Pull requests are the greatest contributions, so be sure they are focused in scope, and do avoid unrelated commits.
123 |
124 | - Fork it!
125 | - Clone your fork: `git clone https://github.com//afterframe`
126 | - Navigate to the newly cloned directory: `cd afterframe`
127 | - Create a new branch for the new feature: `git checkout -b my-new-feature`
128 | - Install the tools necessary for development: `npm install`
129 | - Make your changes.
130 | - Commit your changes: `git commit -am 'Add some feature'`
131 | - Push to the branch: `git push origin my-new-feature`
132 | - Submit a pull request with full remarks documenting your changes.
133 |
--------------------------------------------------------------------------------
/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Afterframe Demo
5 |
6 |
7 |
8 |
9 |
10 |
11 |
19 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "afterframe",
3 | "amdName": "afterFrame",
4 | "version": "1.0.2",
5 | "description": "A simple method to invoke a function after the browser has rendered & painted a frame",
6 | "main": "dist/afterframe.js",
7 | "module": "dist/afterframe.module.js",
8 | "unpkg": "dist/afterframe.umd.js",
9 | "umd:main": "dist/afterframe.umd.js",
10 | "source": "src/index.js",
11 | "types": "src/index.d.ts",
12 | "scripts": {
13 | "test": "tsc -p ./test/tsconfig.json && jest --ci --coverage",
14 | "build": "microbundle build",
15 | "prepare": "npm run build",
16 | "prepublishOnly": "npm run test"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git+https://github.com/andrewiggins/afterframe.git"
21 | },
22 | "authors": [
23 | "Andre Wiggins "
24 | ],
25 | "license": "MIT",
26 | "files": [
27 | "src",
28 | "dist"
29 | ],
30 | "bugs": {
31 | "url": "https://github.com/andrewiggins/afterframe/issues"
32 | },
33 | "homepage": "https://github.com/andrewiggins/afterframe#readme",
34 | "devDependencies": {
35 | "@types/jest": "^27.0.1",
36 | "@types/node": "^14.0.26",
37 | "jest": "^27.0.6",
38 | "jest-junit": "^12.2.0",
39 | "microbundle": "^0.13.3",
40 | "typescript": "^4.3.5"
41 | },
42 | "jest": {
43 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.jsx?$",
44 | "coverageReporters": [
45 | "json",
46 | "lcov",
47 | "text",
48 | "cobertura"
49 | ],
50 | "reporters": [
51 | "default",
52 | "jest-junit"
53 | ]
54 | },
55 | "jest-junit": {
56 | "outputDirectory": "./test-results/"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Invoke the given callback after the browser renders the next frame
3 | * @param {(time: number) => void} callback The function to call after the browser renders
4 | * the next frame. The callback function is passed one argument, a DOMHighResTimeStamp
5 | * similar to the one returned by performance.now(), indicating the point in time when
6 | * afterFrame() starts to execute callback functions.
7 | */
8 | export default function afterFrame(callback: (time: number) => void): void;
9 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Queue of functions to invoke
3 | * @type {Array<(time: number) => void>}
4 | */
5 | let callbacks = [];
6 |
7 | let channel = new MessageChannel();
8 |
9 | let postMessage = (function() {
10 | this.postMessage(undefined);
11 | }).bind(channel.port2);
12 |
13 | // Flush the callback queue when a message is posted to the message channel
14 | channel.port1.onmessage = () => {
15 | // Reset the callback queue to an empty list in case callbacks call
16 | // afterFrame. These nested calls to afterFrame should queue up a new
17 | // callback to be flushed in the following frame and should not impact the
18 | // current queue being flushed
19 | let toFlush = callbacks;
20 | callbacks = [];
21 | let time = performance.now();
22 | for (let i = 0; i < toFlush.length; i++) {
23 | // Call all callbacks with the time the flush began, similar to requestAnimationFrame
24 | // TODO: Error handling?
25 | toFlush[i](time);
26 | }
27 | };
28 |
29 | // If the onmessage handler closes over the MessageChannel, the MessageChannel never gets GC'd:
30 | channel = null;
31 |
32 | /**
33 | * Invoke the given callback after the browser renders the next frame
34 | * @param {(time: number) => void} callback The function to call after the browser renders
35 | * the next frame. The callback function is passed one argument, a DOMHighResTimeStamp
36 | * similar to the one returned by performance.now(), indicating the point in time when
37 | * afterFrame() starts to execute callback functions.
38 | */
39 | export default function afterFrame(callback) {
40 | if (callbacks.push(callback) === 1) {
41 | requestAnimationFrame(postMessage);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------
1 | // Validate correct TS import statement
2 | import afterFrame from "../";
3 |
4 | type Callback = (time: number) => void;
5 |
6 | describe("afterFrame", () => {
7 | let afterFrame: typeof import("../").default;
8 |
9 | class MessagePortMock {
10 | public otherPort: MessagePortMock | undefined;
11 | public onmessage: ((event: any) => void) | undefined;
12 |
13 | public postMessage() {
14 | if (this.otherPort && this.otherPort.onmessage) {
15 | this.otherPort.onmessage({});
16 | }
17 | }
18 | }
19 |
20 | class MessageChannelMock {
21 | public port1 = new MessagePortMock();
22 | public port2 = new MessagePortMock();
23 |
24 | constructor() {
25 | this.port1.otherPort = this.port2;
26 | this.port2.otherPort = this.port1;
27 | }
28 | }
29 |
30 | let rAFCallback: Callback | null = null;
31 | function rAFMock(callback: Callback) {
32 | rAFCallback = callback;
33 | return 1;
34 | }
35 |
36 | const delay = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
37 |
38 | async function renderFrame() {
39 | // Wait a beat to simulate these frames running at different times
40 | await delay();
41 |
42 | if (rAFCallback) {
43 | rAFCallback(performance.now());
44 | }
45 | }
46 |
47 | beforeEach(() => {
48 | jest.resetModules();
49 | rAFCallback = null;
50 | (global as any).performance = { now: Date.now };
51 | (global as any).MessageChannel = MessageChannelMock;
52 | (global as any).requestAnimationFrame = jest.fn(rAFMock);
53 |
54 | afterFrame = require("../");
55 | });
56 |
57 | it("uses rAF and MessageChannel to invoke callback", async () => {
58 | let time;
59 | let callback = jest.fn((t) => {
60 | time = t;
61 | });
62 |
63 | afterFrame(callback);
64 | await renderFrame();
65 |
66 | expect(callback).toHaveBeenCalled();
67 | expect(time).toBeGreaterThan(0);
68 | });
69 |
70 | it("runs multiple callbacks in one rAF for per frame", async () => {
71 | let callback = jest.fn();
72 |
73 | afterFrame(callback);
74 | afterFrame(callback);
75 | afterFrame(callback);
76 |
77 | expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
78 | expect(callback).toHaveBeenCalledTimes(0);
79 |
80 | await renderFrame();
81 | expect(callback).toHaveBeenCalledTimes(3);
82 |
83 | afterFrame(callback);
84 | afterFrame(callback);
85 | afterFrame(callback);
86 |
87 | expect(requestAnimationFrame).toHaveBeenCalledTimes(2);
88 | expect(callback).toHaveBeenCalledTimes(3);
89 |
90 | await renderFrame();
91 | expect(callback).toHaveBeenCalledTimes(6);
92 | });
93 |
94 | it("invokes nested callbacks in new frames", async () => {
95 | let time1: number | undefined,
96 | time2: number | undefined,
97 | time3: number | undefined;
98 | const callback3 = jest.fn((t3) => {
99 | time3 = t3;
100 | });
101 | const callback2 = jest.fn((t2) => {
102 | time2 = t2;
103 | afterFrame(callback3);
104 | });
105 | const callback1 = jest.fn((t1) => {
106 | time1 = t1;
107 | afterFrame(callback2);
108 | });
109 |
110 | // Schedule callback
111 | afterFrame(callback1);
112 |
113 | expect(callback1).toHaveBeenCalledTimes(0);
114 | expect(callback2).toHaveBeenCalledTimes(0);
115 | expect(callback3).toHaveBeenCalledTimes(0);
116 | expect(time1).toBeUndefined();
117 | expect(time2).toBeUndefined();
118 | expect(time3).toBeUndefined();
119 |
120 | // First frame
121 | await renderFrame();
122 |
123 | expect(callback1).toHaveBeenCalledTimes(1);
124 | expect(callback2).toHaveBeenCalledTimes(0);
125 | expect(callback3).toHaveBeenCalledTimes(0);
126 | expect(time1).toBeGreaterThan(0);
127 | expect(time2).toBeUndefined();
128 | expect(time3).toBeUndefined();
129 |
130 | // Second frame
131 | await renderFrame();
132 |
133 | expect(callback1).toHaveBeenCalledTimes(1);
134 | expect(callback2).toHaveBeenCalledTimes(1);
135 | expect(callback3).toHaveBeenCalledTimes(0);
136 | expect(time1).toBeGreaterThan(0);
137 | expect(time2).toBeGreaterThan(time1 as number);
138 | expect(time3).toBeUndefined();
139 |
140 | // Third frame
141 | await renderFrame();
142 |
143 | expect(callback1).toHaveBeenCalledTimes(1);
144 | expect(callback1).toHaveBeenCalledTimes(1);
145 | expect(callback1).toHaveBeenCalledTimes(1);
146 | expect(time1).toBeGreaterThan(0);
147 | expect(time2).toBeGreaterThan(time1 as number);
148 | expect(time3).toBeGreaterThan(time2 as number);
149 | });
150 |
151 | it("accepts callbacks that ignore the time argument", async () => {
152 | // Primarly a TypeScript types test
153 | let invoked = false;
154 | afterFrame(() => (invoked = true));
155 | await renderFrame();
156 |
157 | expect(invoked).toBe(true);
158 | });
159 | });
160 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "strict": true,
5 | "target": "es2017",
6 | "types": ["jest", "node"]
7 | },
8 | "include": ["*.ts"]
9 | }
10 |
--------------------------------------------------------------------------------