├── CHANGELOG.md ├── .gitignore ├── src ├── EmptyContext.ts ├── index.ts ├── RenderPending.ts ├── PromiseChain.ts ├── globals.ts ├── SingleEntryCache.ts ├── Cache.ts ├── HashCache.ts ├── profiling.ts ├── devTools.ts ├── Context.ts ├── util.ts └── Component.ts ├── babel.config.js ├── rollup.config.js ├── tsconfig.json ├── package.json ├── docs ├── DevTools.md ├── Concepts.md ├── Development.md ├── DosDonts.md ├── ReactComparison.md ├── Notebooks.md └── API.md ├── LICENSE ├── test ├── util.spec.ts └── Component.spec.ts ├── CONTRIBUTING.md └── README.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | 3 | * Initial release 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | lib/ 3 | node_modules/ 4 | reback-js.iml 5 | -------------------------------------------------------------------------------- /src/EmptyContext.ts: -------------------------------------------------------------------------------- 1 | import Context from './Context'; 2 | 3 | export default new Context(); 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | ['@babel/preset-typescript', {allowDeclareFields: true}], 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import globals from './globals'; 2 | import * as devTools from './devTools'; 3 | 4 | export {default as Component} from './Component'; 5 | export type {AnyComponent} from './Component'; 6 | export {default as Context} from './Context'; 7 | export {isRenderPending} from './RenderPending'; 8 | 9 | // Expose the devTools functions under a global object `_r`, for easy access during debugging. 10 | globals._r = devTools; 11 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import typescript from '@rollup/plugin-typescript'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: 'src/index.ts', 7 | output: { 8 | format: 'cjs', 9 | dir: 'lib', 10 | exports: 'named', 11 | sourcemap: true, 12 | strict: true 13 | }, 14 | external: ['process'], 15 | plugins: [ 16 | typescript(), 17 | resolve(), 18 | commonjs() 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /src/RenderPending.ts: -------------------------------------------------------------------------------- 1 | import {DEBUG_REBACK} from "./globals"; 2 | 3 | function RenderPendingDebug(cause?: any) { 4 | // This version of the exception constructor is used when DEBUG_REBACK is enabled. 5 | // It "remembers" an object passed in which describes why the component is pending, 6 | // which can be useful for debugging. 7 | this.cause = cause; 8 | } 9 | 10 | function RenderPendingPrd() {} 11 | 12 | const RenderPending = DEBUG_REBACK ? RenderPendingDebug : RenderPendingPrd; 13 | 14 | export function isRenderPending(exc) { 15 | return exc instanceof RenderPending; 16 | } 17 | 18 | export default RenderPending; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*.ts" 4 | ], 5 | "exclude": [ 6 | "node_modules" 7 | ], 8 | "compilerOptions": { 9 | "baseUrl": ".", 10 | "module": "ES2020", 11 | "moduleResolution": "Node", 12 | "outDir": "lib/", 13 | "declaration": true, 14 | "allowJs": true, 15 | "lib": ["ES2017", "DOM"], 16 | "target": "ES2015", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "allowSyntheticDefaultImports": true, 20 | "skipLibCheck": true, 21 | "strictBindCallApply": true, 22 | "strictFunctionTypes": true, 23 | "strictNullChecks": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reback-js", 3 | "version": "1.0.0", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "rollup -c", 9 | "test": "jest" 10 | }, 11 | "dependencies": { 12 | "loggers-js": "^1.0.0", 13 | "sync-promise-js": "^1.0.0" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.15.5", 17 | "@babel/preset-env": "^7.15.6", 18 | "@babel/preset-typescript": "^7.15.0", 19 | "@rollup/plugin-commonjs": "^21.0.0", 20 | "@rollup/plugin-node-resolve": "^13.0.5", 21 | "@rollup/plugin-typescript": "^8.2.5", 22 | "@types/jest": "^27.0.2", 23 | "@types/node": "^16.10.2", 24 | "jest": "^27.2.4", 25 | "rollup": "^2.58.0", 26 | "tslib": "^2.3.1", 27 | "typescript": "^4.4.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docs/DevTools.md: -------------------------------------------------------------------------------- 1 | # Reback developer tools 2 | 3 | Reback comes with some extra functions useful for development, debugging, and profiling. 4 | 5 | Reback exposes a global object `_r` that provides access to the developer tools. It has the following members: 6 | 7 | * `_r.logRenderTree(component)`: logs the render tree starting at a certain component. 8 | * `_r.logRootRenderTree(component)`: logs the render tree at the top-level ancestor of a component. 9 | * `_r.logParents(component)`: logs all the ancestors of a component. 10 | 11 | There are also tools for profiling, which are enabled by setting `PROFILE = true` in the Webpack configuration file: 12 | 13 | * `_r.timings`: dictionary of timings by name. Timings are hooked up in code by calling the functions `startTiming(name)` and `stopTiming(name)` exported by `reback/devTools.js`. 14 | -------------------------------------------------------------------------------- /src/PromiseChain.ts: -------------------------------------------------------------------------------- 1 | import SyncPromise from 'sync-promise-js'; 2 | 3 | export default class PromiseChain { 4 | chain: SyncPromise; 5 | 6 | static whenDone(promiseFunc: () => SyncPromise) { 7 | return SyncPromise.defer().then(() => { 8 | const promise = promiseFunc(); 9 | if (promise.isSettled()) { 10 | return SyncPromise.resolve(); 11 | } else { 12 | return promise.then(() => { 13 | return PromiseChain.whenDone(promiseFunc); 14 | }); 15 | } 16 | }); 17 | } 18 | 19 | constructor() { 20 | this.chain = SyncPromise.resolve(); 21 | } 22 | 23 | add(task: () => SyncPromise) { 24 | this.chain = this.chain.then(task); 25 | return this.chain; 26 | } 27 | 28 | get() { 29 | return this.chain; 30 | } 31 | 32 | isSettled() { 33 | return this.chain.isSettled(); 34 | } 35 | 36 | whenDone() { 37 | return PromiseChain.whenDone(this.get.bind(this)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Wolfram Research Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /docs/Concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | We deal with *components* such as the notebook, cells, cell groups, boxes, and even things like options and dynamic values can be thought of as components (they might not actually render as something "visual", but they are still part of the *render tree* and have a certain *lifecycle*). 4 | 5 | Components rendered during another component's rendering are considered the *(rendered) children* of that other component, which is called their *parent*. The set of a component's children, children's children (grand children), etc. is called the component's *descendants*. The set of a component's parent, parent's parent (grand parent), etc. is called the component's *ancestors*. The top-level component (which is rendered from code outside any other component and hence doesn't have any parent) is called the *root* component. 6 | 7 | Since `Backbone.Model` already implements the concept of a model with observable attributes and events, we use it as the base class of `Component`. The analogy to React's `state` are Backbone attributes. 8 | 9 | Read more about [how Reback is used to implement the notebook interface](./Notebooks.md). 10 | 11 | Reback also provides some useful [developer tools](./DevTools.md). 12 | -------------------------------------------------------------------------------- /docs/Development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | Please read the [Contributing Agreement](../CONTRIBUTING.md) first. By contributing to this repository, you agree to the licensing terms herein. 4 | 5 | To install all required dependencies for development of this library, run: 6 | 7 | yarn install 8 | 9 | ## Test 10 | 11 | To run the tests: 12 | 13 | yarn test 14 | 15 | ## Releasing a new version 16 | 17 | To release a new version, log in to npm using 18 | 19 | yarn login 20 | 21 | as an owner of this package. 22 | 23 | Check out the `master` branch and make sure there are no uncommitted changes: 24 | 25 | git checkout master 26 | 27 | Then run 28 | 29 | yarn publish 30 | 31 | which asks for the new package version, updates `package.json` accordingly, runs a build, creates a Git tag, and publishes the package. 32 | 33 | If publishing fails due to missing authentication even though you have run `yarn login`, you might have to delete `~/.npmrc` and log in again (see [this Yarn issue](https://github.com/yarnpkg/yarn/issues/4709)). 34 | 35 | If [two-factor authentication](https://docs.npmjs.com/configuring-two-factor-authentication) is enabled for your account, you will be asked for a one-time password during the publishing process. 36 | -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | 3 | let globalScope; 4 | let hasWindow = false; 5 | if (typeof window !== 'undefined') { 6 | globalScope = window; 7 | hasWindow = true; 8 | } else { // @ts-ignore 9 | if (typeof global !== 'undefined') { 10 | // @ts-ignore 11 | globalScope = global; 12 | } else if (typeof self !== 'undefined') { 13 | globalScope = self; 14 | } else { 15 | // cf. http://www.2ality.com/2014/05/this.html 16 | // and http://speakingjs.com/es5/ch23.html#_indirect_eval_evaluates_in_global_scope 17 | globalScope = eval.call(null, 'this'); // eslint-disable-line no-eval 18 | } 19 | } 20 | // Assign to a constant to avoid exporting a mutable variable (which ESLint doesn't like). 21 | const globalScopeConst = globalScope; 22 | 23 | export default globalScopeConst; 24 | 25 | export const now = 26 | globalScope && globalScope.performance && globalScope.performance.now ? () => performance.now() : () => Date.now(); 27 | 28 | export const DEBUG = process.env.NODE_ENV !== 'production'; 29 | export const DEBUG_REBACK = process.env.DEBUG_REBACK === 'true'; 30 | export const PROFILE_REBACK = process.env.PROFILE_REBACK === 'true'; 31 | export const IS_SERVER = process.env.IS_SERVER === 'true'; 32 | export const TESTING = process.env.TESTING === 'true'; 33 | -------------------------------------------------------------------------------- /test/util.spec.ts: -------------------------------------------------------------------------------- 1 | import {mergeSortedArrays} from '../src/util'; 2 | 3 | describe('Reback utils', () => { 4 | describe('mergeSortedArrays', () => { 5 | it('merges arrays in sorted order', () => { 6 | const target = [1, 3, 5]; 7 | const source = [0, 2]; 8 | mergeSortedArrays(target, source); 9 | expect(target).toEqual([0, 1, 2, 3, 5]); 10 | }); 11 | it('unifies duplicates', () => { 12 | const target = [1, 2, 3]; 13 | const source = [2, 4, 5]; 14 | mergeSortedArrays(target, source); 15 | expect(target).toEqual([1, 2, 3, 4, 5]); 16 | }); 17 | it('handles the case of the last target value being equal to a source value', () => { 18 | const target = [1, 2, 3]; 19 | const source = [3, 4, 5]; 20 | mergeSortedArrays(target, source); 21 | expect(target).toEqual([1, 2, 3, 4, 5]); 22 | }); 23 | it('accepts an empty target', () => { 24 | const target = []; 25 | const source = [1, 2, 3]; 26 | mergeSortedArrays(target, source); 27 | expect(target).toEqual([1, 2, 3]); 28 | }); 29 | it('accepts an empty source', () => { 30 | const target = [1, 2, 3]; 31 | const source = []; 32 | mergeSortedArrays(target, source); 33 | expect(target).toEqual([1, 2, 3]); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/SingleEntryCache.ts: -------------------------------------------------------------------------------- 1 | import Cache from './Cache'; 2 | 3 | /** 4 | * A cache that can store (up to) a single entry. 5 | */ 6 | export default class SingleEntryCache { 7 | /** Hash of the key of the stored entry. `null` means there is no entry. */ 8 | keyHash: string | null; 9 | 10 | /** Cached value. */ 11 | value: Value | null; 12 | 13 | /** Function to determine the hash of a given key. */ 14 | getKeyHash: (k: Key) => string; 15 | 16 | constructor({keyHash = JSON.stringify}: {keyHash?: (k: Key) => string} = {}) { 17 | this.keyHash = null; 18 | this.value = null; 19 | this.getKeyHash = keyHash; 20 | } 21 | 22 | setEntry(key: Key, value: Value) { 23 | this.keyHash = this.getKeyHash(key); 24 | this.value = value; 25 | } 26 | 27 | getEntry(key: Key, defaultValue: Value = Cache.MISSING as any): Value | typeof Cache.MISSING { 28 | if (this.keyHash === null) { 29 | return defaultValue; 30 | } 31 | const hash = this.getKeyHash(key); 32 | if (hash === this.keyHash) { 33 | return this.value as Value; 34 | } 35 | return defaultValue; 36 | } 37 | 38 | empty() { 39 | this.keyHash = null; 40 | this.value = null; 41 | } 42 | 43 | getSize() { 44 | if (this.keyHash === null) { 45 | return 0; 46 | } 47 | return 1; 48 | } 49 | 50 | getMaxSize() { 51 | return 1; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docs/DosDonts.md: -------------------------------------------------------------------------------- 1 | # Dos and Don'ts 2 | 3 | Any *allocations* (e.g. events being registered) should be done in `onAppear` or `onMount`, and they should be "undone" in `onUnmount` or `onDisappear`. 4 | 5 | The constructor of an item should not perform any allocations. (The counterpart to the constructor is garbage collection, which cannot perform any custom deallocations.) 6 | 7 | It is allowed to call `render` multiple times during its parent's `render` (e.g. to figure out the optimal widths of columns in a grid). 8 | 9 | It is not allowed to render the same item into multiple parents simultaneously. 10 | 11 | It is allowed to "move" an item from one parent to another. In that case, `onUnmount` is called before `onMount`. 12 | 13 | Methods starting with `on`, `should`, or `do` (and also `getContextModifications`, `getPrepareContextModifications`, `getMaxRenderCacheSize`) are "virtual" methods meant for overriding. Other methods (such as `render` and `forceRender`) should not be overridden in subclasses of `Component` (they are only there to be *called*). You don't *have* to override any methods, though: A component which does not define any methods is *okay* (although it won't actually do much). 14 | 15 | Components should not manually install event listeners on child components. That happens automatically (with guaranteed cleanup), so that any child events are re-triggered on the parent. Any custom event handling beyond that can be done by overriding `onChildEvent`. Event handlers on the component itself should be defined via the `onChange` property. 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Wolfram® 2 | 3 | Thank you for taking the time to contribute to the [Wolfram Research](https://github.com/wolframresearch) repos on GitHub. 4 | 5 | ## Licensing of Contributions 6 | 7 | By contributing to Wolfram, you agree and affirm that: 8 | 9 | > Wolfram may release your contribution under the terms of the [MIT license](https://opensource.org/licenses/MIT); and 10 | 11 | > You have read and agreed to the [Developer Certificate of Origin](http://developercertificate.org/), version 1.1 or later. 12 | 13 | Please see [LICENSE](LICENSE) for licensing conditions pertaining 14 | to individual repositories. 15 | 16 | 17 | ## Bug reports 18 | 19 | ### Security Bugs 20 | 21 | Please **DO NOT** file a public issue regarding a security issue. 22 | Rather, send your report privately to security@wolfram.com. Security 23 | reports are appreciated and we will credit you for it. We do not offer 24 | a security bounty, but the forecast in your neighborhood will be cloudy 25 | with a chance of Wolfram schwag! 26 | 27 | ### General Bugs 28 | 29 | Please use the repository issues page to submit general bug issues. 30 | 31 | Please do not duplicate issues. 32 | 33 | Please do send a complete and well-written report to us. Note: **the 34 | thoroughness of your report will positively correlate to our willingness 35 | and ability to address it**. 36 | 37 | When reporting issues, always include: 38 | 39 | * Your version of *Mathematica*® or the Wolfram Language. 40 | * Your version of the Wolfram Cloud. 41 | * Your operating system. 42 | * Your web browser, including version number. 43 | -------------------------------------------------------------------------------- /docs/ReactComparison.md: -------------------------------------------------------------------------------- 1 | # Comparison to React's component mechanism 2 | 3 | * We don't manage component *instances*. Instances are created "manually" by instantiating (subclasses of) `Component`. There is no built-in pooling mechanism. 4 | * Because of that, it's easier to "identify" component instances. E.g. we don't need a `key` property to enable "moves" of instances. Instances can even be moved from one parent to another. 5 | * Another implication is that we don't distinguish between *components* and *instances* as React does. When we say "component", it is an actual instance of a component class. 6 | * We also don't distinguish between *owners* and *parents*. (Essentially, what we call "parent" would be the "owner" in React.) 7 | * `render` does not necessarily return a `ReactElement`, but can return a custom result that can also return dimension information (width/height/baseline). `render` can receive a custom argument such as layout information (especially the current layout width). 8 | * Hence, we have explicit calls to `render`. This also allows us to repeatedly render a component instance as part of a layout algorithm. (It's still only allowed to appear on screen once, though.) 9 | * Backbone attributes are the analogy to React's `state`. Changing an attribute causes a component (and its ancestors) to re-render. 10 | * Parameters to `render` (in the form of "named parameters", i.e. properties of the object passed to `render`) are the analogy to React's `props`. Passing in different parameters causes a component to re-render, unless there is a render cache for the given parameters already (which hasn't been invalidated yet, e.g. by changing attributes or a changed context). 11 | * `getRenderResult` is similar to React's `ref`s in that it can be used to reach into the rendered result, e.g. to retrieve the dimensions stored in a rendered item after a render pass. 12 | -------------------------------------------------------------------------------- /src/Cache.ts: -------------------------------------------------------------------------------- 1 | function equals(a, b) { 2 | return a === b; 3 | } 4 | 5 | export default class Cache { 6 | entries: any[]; 7 | maxSize: number | (() => number); 8 | keyComparator: (a: any, b: any) => boolean; 9 | 10 | static MISSING = {}; 11 | 12 | constructor({ 13 | maxSize = -1, 14 | keyComparator = equals 15 | }: {maxSize?: number | (() => number); keyComparator?: (a: any, b: any) => boolean} = {}) { 16 | this.entries = []; 17 | this.maxSize = maxSize; 18 | this.keyComparator = keyComparator; 19 | } 20 | 21 | setEntry(key, value) { 22 | const existingIndex = this._findEntry(key); 23 | if (existingIndex >= 0) { 24 | this.entries[existingIndex].value = value; 25 | } else { 26 | // Add a new entry to the front, since there is no entry for the given key yet. 27 | const maxSize = this.getMaxSize(); 28 | if (maxSize !== 0) { 29 | this.entries.unshift({key, value}); 30 | } 31 | if (maxSize >= 0 && this.entries.length > maxSize) { 32 | // If we exceed the maximum cache size, remove the last entry. 33 | this.entries.pop(); 34 | } 35 | } 36 | } 37 | 38 | getEntry(key, defaultValue = Cache.MISSING) { 39 | const index = this._findEntry(key); 40 | if (index >= 0) { 41 | return this.entries[index].value; 42 | } else { 43 | return defaultValue; 44 | } 45 | } 46 | 47 | getSize() { 48 | return this.entries.length; 49 | } 50 | 51 | empty() { 52 | this.entries = []; 53 | } 54 | 55 | getMaxSize() { 56 | const maxSize = this.maxSize; 57 | return typeof maxSize === 'function' ? maxSize() : maxSize; 58 | } 59 | 60 | _findEntry(key) { 61 | const {entries, keyComparator} = this; 62 | for (let i = 0, l = entries.length; i < l; ++i) { 63 | const entry = entries[i]; 64 | if (keyComparator(key, entry.key)) { 65 | return i; 66 | } 67 | } 68 | return -1; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/HashCache.ts: -------------------------------------------------------------------------------- 1 | import Cache from './Cache'; 2 | 3 | export default class HashCache { 4 | entries: Map; 5 | keysOrder: (HashType | null)[]; 6 | maxSize: number | (() => number); 7 | keyHash: (key: KeyType) => HashType; 8 | 9 | constructor({ 10 | maxSize = -1, 11 | keyHash = JSON.stringify as any 12 | }: {readonly maxSize?: number; readonly keyHash?: (key: KeyType) => HashType} = {}) { 13 | this.entries = new Map(); 14 | this.keysOrder = []; 15 | this.maxSize = maxSize; 16 | this.keyHash = keyHash; 17 | } 18 | 19 | setEntry(key: KeyType, value: ValueType): void { 20 | const hash = this.keyHash(key); 21 | const {entries, keysOrder} = this; 22 | const maxSize = this.getMaxSize(); 23 | if (maxSize >= 0 && entries.size >= maxSize) { 24 | // If the size is already at the maximum (or even exceeds it), 25 | // then delete the first entry that doesn't correspond to the new key 26 | // ("first" in insertion order). 27 | for (let i = 0, l = keysOrder.length; i < l; ++i) { 28 | const keyToDelete = keysOrder[i]; 29 | if (keyToDelete && keyToDelete !== hash) { 30 | entries.delete(keyToDelete); 31 | keysOrder[i] = null; 32 | break; 33 | } 34 | } 35 | } 36 | if (maxSize !== 0) { 37 | if (!entries.has(hash)) { 38 | keysOrder.push(hash); 39 | } 40 | entries.set(hash, value); 41 | } 42 | } 43 | 44 | getEntry(key: KeyType): ValueType | typeof Cache.MISSING; 45 | getEntry(key: KeyType, defaultValue: ValueType | DefaultType): ValueType | DefaultType; 46 | 47 | getEntry( 48 | key: KeyType, 49 | defaultValue: ValueType | DefaultType = Cache.MISSING as any 50 | ): ValueType | DefaultType { 51 | const hash = this.keyHash(key); 52 | const {entries} = this; 53 | 54 | const entry = entries.get(hash); 55 | 56 | if (entry !== undefined) { 57 | return entry; 58 | } 59 | 60 | if (entries.has(hash)) { 61 | // There is a key, so the entry isn't missing, the value is just literally `undefined`. 62 | return entry as any; 63 | } 64 | 65 | return defaultValue; 66 | } 67 | 68 | empty(): void { 69 | this.entries.clear(); 70 | this.keysOrder = []; 71 | } 72 | 73 | getSize(): number { 74 | return this.entries.size; 75 | } 76 | 77 | getMaxSize(): number { 78 | const maxSize = this.maxSize; 79 | return typeof maxSize === 'function' ? maxSize() : maxSize; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reback 2 | 3 | A framework to define encapsulated components that make up a render tree. 4 | 5 | Reback combines some of the ideas of [React](https://reactjs.org/) and [Backbone.js](https://backbonejs.org/). 6 | 7 | ## Goals 8 | 9 | Reback provides a unified way to define various *components*, e.g. in the implementation of a [notebook interface](https://www.wolfram.com/notebooks/) (notebooks, cells, boxes, options, dynamic values, etc). They all have in common that 10 | 11 | * they need to be aware of their *lifecycle* (when they appear, disappear, etc), 12 | * there is a parent-child relationship between components, resulting in a render tree, 13 | * they need a way to define how to *render* themselves, 14 | * there is a *context* being passed through all levels of the render tree (e.g. current styles). 15 | 16 | Instead of managing the lifecycle of boxes and parent-child relationships explicitly (which would be error-prone), we "declare" the dependencies during `render` and everything follows from that. Rendering a component *B* during another component *A*'s render pass automatically makes *B* the child of *A*, and when *B* is not rendered anymore as part of *A*, we know that *B* disappeared. There's often no need to attach event handlers explicitly (which poses a risk for memory leaks), instead events and render requests propagate automatically through the render tree. 17 | 18 | This is very much like React, except that 19 | 20 | * we need a more generalized form of `render()`, where we can pass in certain arguments (e.g. the layout width) and can return results that are not (ReactDOM) elements (e.g. the dimensions of the returned nodes), 21 | * we need to be able to render a particular child multiple times during its parent's rendering (e.g. a GridBox "probes" its children multiple times to find the ideal column widths), 22 | * we want to manage instantiation of components ourselves, e.g. so that a cell is not re-instantiated when the group structure changes (which would happen in React due to the way reconciliation works). 23 | 24 | Read more about the [differences to React](docs/ReactComparison.md). 25 | 26 | Furthermore, we want a way to express asynchronous *preparation* of components. While a component is being prepared, it can define a certain way to render in this *pending* state, and we can also express things like "render a parent as pending as long as any of its children are pending". 27 | 28 | ## Installation 29 | 30 | Assuming you are using a package manager such as [npm](https://www.npmjs.com/get-npm) or [Yarn](https://yarnpkg.com/en/), just install this package from the npm repository: 31 | 32 | npm install reback-js 33 | 34 | Then you can import `Component` and other members in your JavaScript code: 35 | 36 | import {Component} from 'reback-js'; 37 | 38 | ## Usage & Documentation 39 | 40 | * **[Concepts](docs/Concepts.md)** 41 | * **[API](docs/API.md)** 42 | * [Dos and Don'ts](docs/DosDonts.md) 43 | * [Developer tools](docs/DevTools.md) 44 | * [Reback in the notebook world](docs/Notebooks.md) 45 | * [Comparison to React's component mechanism](docs/ReactComparison.md) 46 | 47 | ## Contributing 48 | 49 | Everyone is welcome to contribute. Please read the [Contributing agreement](CONTRIBUTING.md) and the [Development guide](./docs/Development.md) for more information, including how to run the tests. 50 | 51 | ## Versioning 52 | 53 | We use [semantic versioning](https://semver.org/) for this library and its API. 54 | 55 | See the [changelog](CHANGELOG.md) for details about the changes in each release. 56 | -------------------------------------------------------------------------------- /src/profiling.ts: -------------------------------------------------------------------------------- 1 | import {PROFILE_REBACK, IS_SERVER} from "./globals"; 2 | 3 | const performance = PROFILE_REBACK && !IS_SERVER && typeof window !== 'undefined' ? window.performance : null; 4 | 5 | let eventID: number = 0; 6 | 7 | function createMark(name: string): void { 8 | if (performance) { 9 | performance.clearMarks(name); 10 | performance.mark(name); 11 | } 12 | } 13 | 14 | function createMeasure(name: string, startMark: string, endMark: string): void { 15 | if (performance) { 16 | performance.measure(name, startMark, endMark); 17 | } 18 | } 19 | 20 | export function dispatchProfileEvent(value: any | null | undefined): number { 21 | if (PROFILE_REBACK) { 22 | const id = eventID++; 23 | 24 | window.dispatchEvent( 25 | new CustomEvent('profile', { 26 | detail: {id, value} 27 | }) 28 | ); 29 | 30 | return id; 31 | } 32 | return -1; 33 | } 34 | 35 | export function start(name: string): void { 36 | createMark(`${name}-start`); 37 | } 38 | 39 | export function end(name: string) { 40 | if (performance) { 41 | const startMark = `${name}-start`; 42 | const endMark = `${name}-end`; 43 | 44 | createMark(endMark); 45 | createMeasure(name, startMark, endMark); 46 | } 47 | } 48 | 49 | export function mark(name: string) { 50 | if (console.timeStamp) { 51 | console.timeStamp(name); 52 | } 53 | } 54 | 55 | export function count(name: string) { 56 | if (console.count) { 57 | console.count(name); 58 | } 59 | } 60 | 61 | if (PROFILE_REBACK && typeof window !== 'undefined') { 62 | class Intervals { 63 | intervals: Array<[number, number]> = []; 64 | 65 | add(x, y) { 66 | let newStart = x; 67 | let newEnd = y; 68 | this.intervals.forEach(([a, b], index) => { 69 | if (a >= newStart && b <= newEnd) { 70 | // The existing interval is entirely inside the new one. Remove the existing one. 71 | this.intervals[index] = [a, a]; 72 | } else if (a < newStart && newStart < b && b < newEnd) { 73 | // The end point of the existing interval is inside the new one. Set the new one start at that end. 74 | newStart = b; 75 | } else if (newStart < a && a < newEnd && b > newEnd) { 76 | // The start point of the existing interval is inside the new one. Make the new end at that start. 77 | newEnd = a; 78 | } 79 | }); 80 | if (newEnd > newStart) { 81 | this.intervals.push([newStart, newEnd]); 82 | } 83 | } 84 | 85 | getNonOverlapping() { 86 | return this.intervals.reduce((total, [a, b]) => total + b - a, 0); 87 | } 88 | } // eslint-disable-next-line no-underscore-dangle 89 | 90 | (window as any)._getProfileTimings = () => { 91 | const measures = window.performance.getEntriesByType('measure'); 92 | const timings = {}; 93 | for (const measure of measures) { 94 | timings[measure.name] = (timings[measure.name] || 0) + measure.duration; 95 | } 96 | return timings; 97 | }; 98 | 99 | // eslint-disable-next-line no-underscore-dangle 100 | (window as any)._getNonOverlappingTimings = () => { 101 | const measures = window.performance.getEntriesByType('measure'); 102 | const timings: Record = {}; 103 | 104 | for (const measure of measures) { 105 | let intervals = timings[measure.name]; 106 | if (!intervals) { 107 | intervals = timings[measure.name] = new Intervals(); 108 | } 109 | intervals.add(measure.startTime, measure.startTime + measure.duration); 110 | } 111 | 112 | const result = {}; 113 | Object.keys(timings).forEach(name => { 114 | const intervals = timings[name]; 115 | result[name] = intervals.getNonOverlapping(); 116 | }); 117 | return result; 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /src/devTools.ts: -------------------------------------------------------------------------------- 1 | import {anyUsedAttribute} from './Context'; 2 | 3 | import type Context from './Context'; 4 | import type {AnyComponent} from './Component'; 5 | 6 | // This is repeated from Component.js, just to avoid having to import it here. 7 | const MASK_PHASE = 0b111; 8 | 9 | const PHASE_NAMES = ['creating', 'mounting', 'preparing', 'rendering', 'rendered', 'unmounting', 'restoring']; 10 | const PHASE_COLORS = { 11 | creating: 'gray', 12 | mounting: 'yellow', 13 | preparing: 'orange', 14 | rendering: 'blue', 15 | rendered: 'green', 16 | unmounting: 'red', 17 | restoring: 'green' 18 | }; 19 | 20 | type LogOptions = { 21 | /** List of component names to "prune" in the render tree, i.e. their descendants will not be shown. */ 22 | prune?: string[]; 23 | 24 | /** Context key to compare at each level in the render tree. */ 25 | compareContext?: string; 26 | 27 | _highlight?: AnyComponent[]; 28 | _indentation?: number; 29 | }; 30 | 31 | // Remember a reference to the original `console.log`, before 32 | // setOutputFunction in loggers.js gets a chance to overwrite it. 33 | // This is useful so we can use the Reback devtools in a debugging session 34 | // during server-side rendering. 35 | // We just need to ensure that this is not called during regular program execution, 36 | // since it could mess with the Java-JS communication. 37 | // eslint-disable-next-line no-console 38 | const consoleLog = console.log; 39 | 40 | function logComponent(component: AnyComponent, {compareContext, _indentation = 0, _highlight = []}: LogOptions = {}) { 41 | const isHighlighted = _highlight.indexOf(component) >= 0; 42 | let prefix = ''; 43 | for (let i = 0; i < _indentation; ++i) { 44 | prefix += ' '; 45 | } 46 | if (isHighlighted) { 47 | prefix = `--> ${prefix.substr(4)}`; 48 | } 49 | const data = component._reback; 50 | const cacheSize = data._renderCache.getSize(); 51 | const readyState = component.isPrepared() ? 'is ready' : 'not ready'; 52 | const phase = PHASE_NAMES[data.flags & MASK_PHASE]; 53 | let extra = ''; 54 | let extraArgs: any[] = []; 55 | if (compareContext) { 56 | // Compare a given context value, between what the component got from its parent and the modified context 57 | // it will propagate to its children. 58 | const context: Context = component.getContext(); 59 | const parentValue = context.get(compareContext); 60 | const childValue = component.getModifiedContext().get(compareContext); 61 | if (!context.sameValue(parentValue, childValue, compareContext)) { 62 | extra = ': %O -> %O'; 63 | extraArgs = [parentValue, childValue]; 64 | } 65 | } 66 | consoleLog( 67 | `%c${prefix}%c%O (%c${readyState}%c, %c${phase}%c, cache size: ${cacheSize})${extra}`, 68 | isHighlighted ? 'background: yellow' : '', 69 | '', 70 | component, 71 | component.isPrepared() ? 'color: green' : 'color: red', 72 | '', 73 | `color: ${PHASE_COLORS[phase]}`, 74 | '', 75 | ...extraArgs 76 | ); 77 | } 78 | 79 | export function logRenderTree(component: AnyComponent, {prune = [], _indentation = 0, ...rest}: LogOptions = {}) { 80 | logComponent(component, {_indentation, ...rest}); 81 | const name = component.constructor.name; 82 | if (prune.indexOf(name) < 0 && component._reback.childrenData) { 83 | component._reback.childrenData.renderedChildren.forEach(({instance}) => { 84 | logRenderTree(instance, {prune, _indentation: _indentation + 1, ...rest}); 85 | }); 86 | } 87 | } 88 | 89 | export function logRootRenderTree(component: AnyComponent, options: LogOptions) { 90 | const highlight = [component]; 91 | let root = component; 92 | let parent; 93 | // eslint-disable-next-line no-cond-assign 94 | while ((parent = root.getParent())) { 95 | root = parent; 96 | highlight.push(parent); 97 | } 98 | logRenderTree(root, {...options, _highlight: highlight}); 99 | } 100 | 101 | export function logParents(component: AnyComponent) { 102 | const parents = [component]; 103 | let parent: AnyComponent | null = component; 104 | // eslint-disable-next-line no-cond-assign 105 | while ((parent = parent.getParent())) { 106 | parents.push(parent); 107 | } 108 | parents.reverse(); 109 | parents.forEach(currentParent => { 110 | logComponent(currentParent, {_highlight: [component]}); 111 | }); 112 | } 113 | 114 | const timingStarts = {}; 115 | export const timings = {}; 116 | 117 | export function startTiming(name: string) { 118 | timingStarts[name] = performance.now(); 119 | } 120 | 121 | export function stopTiming(name: string) { 122 | const stop = performance.now(); 123 | const start = timingStarts[name]; 124 | if (start) { 125 | timings[name] = (timings[name] || 0) + stop - start; 126 | } 127 | } 128 | 129 | export function getUsedContextAttributes(component: AnyComponent) { 130 | const result: string[] = []; 131 | anyUsedAttribute(component._reback._usedContextAttributes, name => { 132 | result.push(name); 133 | return false; 134 | }); 135 | return result; 136 | } 137 | -------------------------------------------------------------------------------- /docs/Notebooks.md: -------------------------------------------------------------------------------- 1 | # Reback in the notebook world 2 | 3 | This document describes some of the details of how Reback is used to implement a notebook interface. 4 | 5 | ## Where to do typical cell/box-related things 6 | 7 | * basic processing of underlying expression: in `initialize` 8 | * create child box instances: in `initialize` (based on `this.expr`) 9 | * create a `DynamicValue` instance: in `initialize` (based on `this.expr`) or (if it depends on options) in `doPrepare` 10 | * retrieve the value of a `DynamicValue`: in `doPrepare` or `doRender`, usually using its method `renderRequired` (which will return a dictionary of the form `{value, serverValue, ?box}`) 11 | * as a Dynamic changes, it will `forceRender` itself (hence also its parent, the consumer of the Dynamic) 12 | * do not use the `change:value` event listener on a `DynamicValue` anymore; rely on automatic re-rendering instead 13 | * don't forget to render a `DynamicValue` during your box's render pass, otherwise the box will not get notified of changes (and you won't be able to take into account the dynamic value anyway) 14 | * create an `Options` instance: in `initialize` or `postInitialize` (based on `this.expr`) 15 | * but you don't have to do that explicitly; it is built into `OptionsComponent`, a super class of cells and boxes 16 | * retrieve option values: in `doPrepare` or `doRender`, usually using its method `renderRequired` (which will return a dictionary of all option and their resolved values) 17 | * as options change, they will `forceRender` themselves (hence also their parent, the consumer of the options) 18 | * the prepare result of any `OptionsComponent` (e.g. cells and boxes) contains a dictionary of `resolvedOptions`; hence explicit calls to `options.resolve` are usually not necessary (but okay, especially in existing code) 19 | 20 | ## `render` vs. `renderRequired` vs. `renderOptional` 21 | 22 | * Use `renderRequired` whenever you assume that the render result is coming from the component's `doRender` (and not `doRenderPending`). Especially in the case of a `DynamicValue`, you'll usually assume that it's ready and you get back an object with a `value`. 23 | * Use `renderOptional` when you explicitly don't care about whether the rendered component is ready or not; e.g. when your component depends on a `DynamicValue` but it should already render non-pendingly (using `doRender`) even if the `DynamicValue` is still pending. 24 | * Use `render` in other cases where you don't want to make an explicit assumption about whether the child is ready or not. Depending on `shouldWaitForChildren`, this is either equivalent to `renderRequired` (if `true`) or `renderOptional` (if `false`). Boxes define `shouldWaitForChildren` to return `true`, i.e. they wait for their children to be ready, equivalently to rendering them with `renderRequired`. 25 | 26 | ## Methods that assume a cell is rendered 27 | 28 | Methods like `Cell.evaluate` need to assume that a cell is ready and rendered, especially that all its options are resolved. The only way to reliably resolve options etc. is to actually render the cell (the options might have `Dynamic` values which need to be visible to resolve). The problem is that, in general, rendering a notebook does not render all cells, but only the visible ones. 29 | 30 | To address this, there is a decorator `@requiresRender` (calling another method `Entity.requireRender`) that ensures that a cell is rendered before actually entering the decorated method. It will render a cell temporarily even if it wouldn't be visible otherwise. 31 | 32 | Note that this does *not* work on the box level, because the nesting of boxes can be much more complicated and it wouldn't be easily possible to ensure that a given box is rendered by its parent (there's no concept of an explicit parent other than the render parent anyway on the box level, in contrast to the notebook/cell level). Given a box, you can only detect whether it is rendered or not (using `.isMounted()`, `.whenRendered()`, etc.) but you can't force it to become visible. 33 | 34 | ## Caveats 35 | 36 | * You cannot access the context in the constructor or `initialize` or any time before a component is mounted. The context is propagated through the tree at render time. If you need the context while "preparing" a box, use it in `doPrepare` (which will be called again whenever the context changes) or `onReceiveContext`. Don't forget to call the inherited method (e.g. `doPrepare` in `OptionsComponent` – which `Box` inherits from – is defined to process options). 37 | * If you render other components in `doPrepare`, you need to do so synchronously, i.e. before any asynchronous "gap" (such as waiting for another promise). Otherwise, the rendered components are not rendered at the right place in the render tree, i.e. their parent will be wrong (`render` might even throw an exception in such a case, since only `renderRoot` is allowed to render a component without a parent). 38 | 39 | ## Editor 40 | 41 | * Editor receives initial box when created 42 | * `content` becomes an attribute of the Editor component 43 | * `Editor.doPrepare` calls `box.linearize()` (in `editor-content.js`) and returns the "processed content" 44 | * `doPrepare` will be re-run iff attributes (esp. `content`) or context change 45 | * `linearize` receives the current `context` as an option; returns linearized version of boxes (array of linear items, which might include reference to original boxes, so they will be rendered as "atomic" parts in the editor) 46 | * `Editor.doRender`: 47 | * receives result from `doPrepare`, i.e. the linearized boxes 48 | * if linearized items are not the same as before (`===` check should be enough): 49 | * if no `CodeMirror` instance exists yet: create a `CodeMirror` instance `cm` with that content 50 | * if `CodeMirror` exists already: update its content (maybe try to preserve cursor position) 51 | -------------------------------------------------------------------------------- /src/Context.ts: -------------------------------------------------------------------------------- 1 | import {PROFILE_REBACK} from "./globals"; 2 | import {sameValueZero} from './util'; 3 | import * as profiling from './profiling'; 4 | 5 | import type {AnyInternalData} from './Component'; 6 | 7 | /** 8 | * Mapping of (string) context names to (numeric) keys. 9 | * This is to avoid storing a potentially large set of strings per component instance, 10 | * using smaller numbers instead, assuming that there are not too many distinct 11 | * context attribute names. 12 | */ 13 | const contextKeysByName: Map = new Map(); 14 | 15 | /** 16 | * Mapping of numeric keys to context names. The reverse of contextKeysByName. 17 | */ 18 | const contextNamesByKey: Array = []; 19 | 20 | /** 21 | * Bits to use per number in an array that represents a bitfield. 22 | * Chosen so that each number in the array is still a (non-negative) small integer ("Smi"). 23 | */ 24 | const BITS_PER_ELEMENT = 30; 25 | 26 | /** 27 | * Sets a bit in a given bitfield to 1. 28 | * @param bitfield Bitfield representing used context attributes. 29 | * @param index Index of the bit to set. 30 | */ 31 | function setBit(bitfield: number[], index: number) { 32 | const elementIndex = Math.floor(index / BITS_PER_ELEMENT); 33 | if (bitfield.length <= elementIndex) { 34 | for (let i = bitfield.length; i <= elementIndex; ++i) { 35 | bitfield[i] = 0; 36 | } 37 | } 38 | bitfield[elementIndex] |= 1 << index % BITS_PER_ELEMENT; 39 | } 40 | 41 | /** 42 | * Iterates over all used attributes in the given bitfield and 43 | * returns true if the given callback returns true for any of them. 44 | * @param bitfield Bitfield representing used context attributes. 45 | * @param callback Function to apply to each used attribute name. 46 | * @returns Whether the callback returned true for any of the used context attributes. 47 | */ 48 | export function anyUsedAttribute(bitfield: number[], callback: (attributeName: string) => boolean): boolean { 49 | for (let i = 0, l = bitfield.length; i < l; ++i) { 50 | const element = bitfield[i]; 51 | if (element) { 52 | for (let j = 0; j < BITS_PER_ELEMENT; ++j) { 53 | if (element & (1 << j)) { 54 | const name = contextNamesByKey[i * BITS_PER_ELEMENT + j]; 55 | if (callback(name)) { 56 | return true; 57 | } 58 | } 59 | } 60 | } 61 | } 62 | return false; 63 | } 64 | 65 | /** 66 | * Adds the used context attributes from source to target. 67 | * @param target Bitfield representing used context attributes, which will be mutated. 68 | * @param source Bitfield representing new used context attributes to be added to the target. 69 | */ 70 | export function addUsedContextAttributes(target: number[], source: number[]) { 71 | const tl = target.length; 72 | const sl = source.length; 73 | let l; 74 | if (tl < sl) { 75 | for (let i = tl; i < sl; ++i) { 76 | target[i] = source[i]; 77 | } 78 | l = tl; 79 | } else { 80 | l = sl; 81 | } 82 | for (let i = 0; i < l; ++i) { 83 | target[i] |= source[i]; 84 | } 85 | } 86 | 87 | export default class Context { 88 | attributes: Map; 89 | componentData: AnyInternalData | null; 90 | 91 | constructor(attrs?: Map) { 92 | // We used to use an Immutable.Map to store the attributes, but it turns out there's quite some 93 | // performance overhead to that, even compared to cloning the whole dictionary whenever modifying it. 94 | // So we stick to pure JS for this. 95 | this.attributes = attrs || new Map(); 96 | this.componentData = null; 97 | } 98 | 99 | clone() { 100 | // @ts-ignore 101 | return new this.constructor(this.attributes); 102 | } 103 | 104 | get(name: string) { 105 | const data = this.componentData; 106 | if (data) { 107 | // Determine the corresponding number key. 108 | let key = contextKeysByName.get(name); 109 | if (key === undefined) { 110 | key = contextNamesByKey.length; 111 | contextNamesByKey.push(name); 112 | contextKeysByName.set(name, key); 113 | } 114 | // Add the used context attribute to the component. 115 | const used = data._usedContextAttributes; 116 | setBit(used, key); 117 | } 118 | return this.attributes.get(name); 119 | } 120 | 121 | changeComponent(componentData: AnyInternalData) { 122 | const clone = this.clone(); 123 | clone.componentData = componentData; 124 | return clone; 125 | } 126 | 127 | change(modifications: {[name: string]: any}) { 128 | PROFILE_REBACK && profiling.start('Context.change'); 129 | let newAttrs: {[name: string]: any} | null = null; 130 | for (const key in modifications) { 131 | if (modifications.hasOwnProperty(key)) { 132 | const newValue = modifications[key]; 133 | const existingValue = this.attributes.get(key); 134 | if (newValue !== existingValue) { 135 | if (!newAttrs) { 136 | newAttrs = new Map(this.attributes); 137 | } 138 | newAttrs.set(key, newValue); 139 | } 140 | } 141 | } 142 | PROFILE_REBACK && profiling.end('Context.change'); 143 | if (newAttrs) { 144 | // @ts-ignore 145 | return new this.constructor(newAttrs); 146 | } else { 147 | return this; 148 | } 149 | } 150 | 151 | getKeys(): Iterable { 152 | return this.attributes.keys(); 153 | } 154 | 155 | sameValue(a: any, b: any, name: string) { 156 | return sameValueZero(a, b, name); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import RenderPending from './RenderPending'; 2 | 3 | /** 4 | * Tests whether two values are the same, with 0 being the same as -0 and NaN being the same as NaN. 5 | * @param a 6 | * @param b 7 | * @returns {boolean} 8 | */ 9 | export function sameValueZero(a: unknown, b: unknown, key?: any) { 10 | // cf. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness 11 | return a === b || (Number.isNaN(a) && Number.isNaN(b)); 12 | } 13 | 14 | /** 15 | * Compares two objects shallowly. 16 | * @param a 17 | * @param b 18 | * @param {function} sameValue 19 | * @returns {boolean} 20 | */ 21 | export function sameShallow(a, b, {sameValue = sameValueZero} = {}) { 22 | if (sameValue(a, b, null)) { 23 | return true; 24 | } 25 | if (!a || !b) { 26 | return false; 27 | } 28 | if (typeof a !== typeof b) { 29 | // Test types to catch cases like `sameShallow(1, {})` where both objects 30 | // have no properties. 31 | return false; 32 | } 33 | for (const key in a) { 34 | if (a.hasOwnProperty(key)) { 35 | if (!b.hasOwnProperty(key) || !sameValue(a[key], b[key], key)) { 36 | return false; 37 | } 38 | } 39 | } 40 | for (const key in b) { 41 | if (b.hasOwnProperty(key)) { 42 | if (!a.hasOwnProperty(key)) { 43 | return false; 44 | } 45 | // If both `a` and `b` have this property, then its value was already compared in the previous loop 46 | // over `a`. 47 | } 48 | } 49 | return true; 50 | } 51 | 52 | /** 53 | * Applies modifications to a context, caching the result and reusing it when the same base and modifications 54 | * are passed in the next time. 55 | * @param {Context} base 56 | * @param {Object} modifications 57 | * @param {{base, modifications, result}} cache 58 | * @param {{sameValue}} options 59 | * @returns {*} 60 | */ 61 | export function applyModificationsCached(base, modifications, cache, options) { 62 | if (!modifications) { 63 | return base; 64 | } 65 | if (cache.base === base && sameShallow(cache.modifications, modifications, options)) { 66 | return cache.result; 67 | } 68 | const result = base.change(modifications); 69 | cache.base = base; 70 | cache.modifications = modifications; 71 | cache.result = result; 72 | return result; 73 | } 74 | 75 | type CompareResult = 76 | | boolean 77 | | { 78 | aMinusB: any[]; 79 | bMinusA: any[]; 80 | differentValue: any[]; 81 | }; 82 | 83 | export function compareMaps(a, b): CompareResult { 84 | if (a === b) { 85 | return true; 86 | } 87 | if (!a || !b) { 88 | return false; 89 | } 90 | const result: CompareResult = {aMinusB: [], bMinusA: [], differentValue: []}; 91 | for (const [key, value] of a) { 92 | if (!b.has(key)) { 93 | result.aMinusB.push(key); 94 | } 95 | if (!sameValueZero(value, b.get(key))) { 96 | result.differentValue.push(key); 97 | } 98 | } 99 | for (const [key, __] of b) { 100 | if (!a.has(key)) { 101 | result.bMinusA.push(key); 102 | } 103 | } 104 | return result; 105 | // This was relevant when using Immutable.Map: 106 | // return compareObjects(a.toObject(), b.toObject()); 107 | } 108 | 109 | /** 110 | * Template string tag for debug output, serializing objects in a log-friendly way. 111 | * cf. https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Template_literals#Tagged_template_literals 112 | * @param strings 113 | * @param values 114 | * @returns {string} 115 | */ 116 | export function d(strings, ...values) { 117 | const result: string[] = []; 118 | for (let i = 0; i < strings.length; ++i) { 119 | result.push(strings[i]); 120 | if (i < values.length) { 121 | const value = values[i]; 122 | let str; 123 | if (value && value.cid) { 124 | str = `[${value.constructor.name}: ${value.cid}]`; 125 | } else if (value === undefined) { 126 | str = 'undefined'; 127 | } else if (typeof value === 'string') { 128 | str = value; 129 | } else if (value instanceof RenderPending) { 130 | str = '[RenderPending]'; 131 | } else if (value instanceof Error) { 132 | str = `[Error: ${value.toString()}`; // + ']'; 133 | if (value.stack) { 134 | str += `\n${value.stack}`; 135 | } 136 | str += ']'; 137 | } else { 138 | try { 139 | str = JSON.stringify(value); 140 | if (str.length > 1000) { 141 | str = `${str.substr(0, 1000)} [...]`; 142 | } 143 | } catch (e) { 144 | // in case of circular references in the object 145 | str = '[Object]'; 146 | } 147 | } 148 | result.push(str); 149 | } 150 | } 151 | return result.join(''); 152 | } 153 | 154 | /** 155 | * Merges two sorted arrays with unique elements into a single sorted array with unique elements. 156 | * @param target Array of numbers to be mutated, so it includes all numbers from the source array. 157 | * @param source Array of numbers to be added to the target array. 158 | * @returns whether the target array was modified 159 | */ 160 | export function mergeSortedArrays(target: number[], source: number[]): boolean { 161 | let index1 = 0; 162 | let index2 = 0; 163 | let didChange = false; 164 | 165 | while (index2 < source.length) { 166 | const val = source[index2]; 167 | const targetLength = target.length; 168 | if (index1 >= targetLength) { 169 | ++index2; 170 | let shouldAdd = true; 171 | if (targetLength > 0) { 172 | const lastTargetVal = target[targetLength - 1]; 173 | if (lastTargetVal === val) { 174 | shouldAdd = false; 175 | } 176 | } 177 | if (shouldAdd) { 178 | didChange = true; 179 | target.push(val); 180 | } 181 | } else { 182 | const targetVal = target[index1]; 183 | if (targetVal < val) { 184 | ++index1; 185 | } else if (targetVal > val) { 186 | ++index2; 187 | didChange = true; 188 | target.splice(index1, 0, val); 189 | } else { 190 | ++index2; 191 | } 192 | } 193 | } 194 | return didChange; 195 | } 196 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # Reback API 2 | 3 | ## Render methods 4 | 5 | Components define the following methods to control how they should be rendered: 6 | 7 | * `doRender(arg, prepareResult)`: called when a component renders which has been prepared. It receives the result from the previous call to [`doPrepare`](#prepare). 8 | * `doRenderPending(arg)`: called when a component is rendered that is not prepared yet (because `doPrepare` returned a promise which is not resolved yet). 9 | * `doRenderError(arg, error)`: called when some error (e.g. an exception in `doPrepare`) prevents the component from being rendered as usual. In addition to the original argument (`arg`) to `render`, it also receives the error that was thrown. 10 | 11 | `Component` defines a method `render` which manages the lifecycle of a component and calls the above render methods as appropriate (forwarding its first argument `arg`). It is usually not necessary to override `render`. This is the method to call to render a component as the child of another component, though. 12 | 13 | * `render(arg, options)`: renders a component using its internal render `doRender*` methods, and returns the result from (one of) them. The rendered component becomes the child of the component that is currently being rendered. If the rendered component is not ready yet (i.e. its `doPrepare` returned a promise which is not resolved yet), the behavior depends on `shouldWaitForChildren`: If that returns `true`, the parent is considered pending (and will render using `doRenderPending`) until all of its children are ready; otherwise the parent will continue rendering using `doRender`, receiving the result of `doRenderPending` from its pending children. 14 | 15 | The following methods can be used to force a particular behavior regardless of `shouldWaitForChildren`. 16 | 17 | * `renderRequired(arg, options)`: renders a component (inside another component), but if it's not prepared yet, make the parent pending as well (instead of "isolating" the "pendingness" to the component itself and rendering via `doRenderPending`). 18 | * `renderOptional(arg, options)`: renders a component (inside another component) and doesn't require it to be prepared yet, regardless of the parent's `shouldWaitForChildren`. 19 | 20 | The top-level (root) component needs to be rendered using a special variant of `render`: 21 | 22 | * `renderRoot(arg, options)`: renders a component as the root of a component tree. Calling `render` outside a render pass initiated by `renderRoot` is an error. (This is simply to make it explicit in the code where a render tree starts vs. where inner components are rendered, to avoid errors further down the road, e.g. when an inner components expects a certain parent or context.) 23 | * `renderRootAsync(arg, options)`: renders a component and returns a promise resolving to the render result. The promise is pending as long as the component is pending. If there is an error while rendering, the returned promise is rejected. *Do not use this in production code yet. Its main purpose is for testing, and this API might change in the future.* 24 | * `unrenderRoot()`: Un-renders a root component, also unmounting all its descendants. This cannot be used on a non-root component; those are unmounted by not rendering them anymore in their parents. 25 | 26 | ### Options 27 | 28 | The first argument (`arg`) passed to `render` can be any application-defined object, e.g. something like `{width: 1000}`. 29 | 30 | Additional options are passed in the second argument (`options`): 31 | 32 | * `context`: An explicit context to use for the component. If this is set, the parent's context (and context modifications) will be ignored. A context object is expected to have at least the methods `.get(key)` and `.change(modifications)`. If no context is given on the root component, a default (empty) `Context` instance will be created. This should usually not be specified, except for a root component. It is recommended to use a subclass of `Context` as the context. 33 | 34 | `options` is for options with a predefined meaning to `Component`. There might be more in the future (and also other internal, undocumented options). Any custom parameters should go into `arg`, so that they don't conflict with options. 35 | 36 | 37 | ## Initialization 38 | 39 | * `initialize(...args)`: initializes a component when it is constructed. Receives the original arguments from the constructor call. 40 | * `postInitialize()`: another initialization pass after `initialize` has run. This is useful if subclasses override `initialize` and, in a superclass, you want to run some code after that initialization. *Use rarely. This API might change.* 41 | 42 | 43 | 44 | ## Lifecycle methods 45 | 46 | As components and their children are rendered, they go through a *lifecycle*, which triggers the following methods being called: 47 | 48 | * `onAppear()`: called when a component appears on screen. 49 | * `onMount()`: called when a component is about to be rendered into a (new) parent (or if a root component is rendered). 50 | * `onUnmount()`: called when a component is not rendered as part of its previous parent anymore. 51 | * `onDisappear()`: called when a component disappears from the screen. 52 | 53 | When a component "moves" from one parent to another, `onUnmount` is called before `onMount`. (Unmounting is not a "final" act like `remove` used to be.) 54 | 55 | `onAppear` is called right before `onMount`; except when the component has already been mounted before (in a different parent), in which case `onAppear` is not called again. 56 | 57 | `onDisappear` is called after `onUnmount` (in a new execution frame); except when the component is mounted again right away (in a different parent), in which case `onDisappear` is not called. 58 | 59 | `onMount` is called before `doRender`. 60 | 61 | Checks determining a component's mounting status: 62 | 63 | * `isRoot()`: returns whether this component was rendered outside of any another component's render tree, using `renderRoot` or `renderRootAsync`. 64 | * `isMounted()`: returns `true` iff this is a root component or part of a root's render tree. `isRoot()` implies `isMounted()`. 65 | 66 | 67 | ## Children 68 | 69 | `onUnmount` is called for all descendants recursively if any of their ancestors is unmounted. 70 | 71 | If a child stays with the same parent, neither `onUnmount` nor `onMount` are called during a render pass. 72 | 73 | `onMount` of a parent is called (and waited to be resolved) before children's `onMount` is called. 74 | 75 | A parent can be made to "wait" for its children to be ready: 76 | 77 | * `shouldWaitForChildren()`: if this returns `true` and a child is still pending during `doRender`, the result of `doRender` is discarded and `doRenderPending` is called on the parent instead. This is equivalent to rendering all children with `renderRequired` instead of `render`. Even if `shouldWaitForChildren` returns `true`, a component can render pending components (without affecting its own pendingness) with `renderOptional`. Default is `false`. 78 | 79 | Accessor methods: 80 | 81 | * `getParent()`: returns the parent of this component. 82 | 83 | To access children, use the following methods: 84 | 85 | * `eachChild(callback)`: iterates over children (similar to `_.each`). 86 | * `mapChildren(callback)`: maps over children (similar to `_.map`), returning a list of results. 87 | * `allChildren(callback)`: iterates over children until any callback returns a falsy value, in which case it returns that value, or `true` otherwise (similar to `_.all`). 88 | 89 | Note that children are only defined *after* `render` has been run. E.g. you can use `eachChild` and `mapChildren` in event handlers that run outside a render pass, but not inside `doRender`. 90 | 91 | 92 | ## Preparation 93 | 94 | Components can define an asynchronous step before `doRender` is called: 95 | 96 | * `doPrepare()`: called before `doRender`. If `doPrepare` returns a promise, the component is rendered as pending (using `doRenderPending`) until the promise resolves. 97 | * `shouldPrepare(changedAttributes)`: determines whether `doPrepare` should be called again if it has been called already. Receives a hash of (Backbone model) attributes that have changed since the last render pass. Default is to return `true` iff any attributes changed. Otherwise, the result from the previous call to `doPrepare` will be reused. Explicit calls to `forcePrepare` and also context changes always invalidate a previous prepare result. 98 | * `forcePrepare()`: clears any cached prepare result and forces a render pass. 99 | 100 | The result of `doPrepare` is passed to both `doRender` and `getContextModifications`. 101 | 102 | `doPrepare` is considered part of the rendering process, so any components rendered therein are considered children of the component. However, such child components need to be rendered *synchronously* in `doPrepare` (i.e. not after an asynchronous operation such as a `setTimeout` or another promise) since they will not be tracked properly otherwise. 103 | 104 | An outer context change invalidates any preparation and causes a component to prepare again when it is rendered the next time. 105 | 106 | While a component is preparing, it is rendered using `doRenderPending` or, if it is required (either because it's rendered via `renderRequired` or because its parent defines `shouldWaitForChildren` to be true), then the parent will be considered pending for as long as the component is preparing. 107 | 108 | Methods to determine the status of asynchronous preparation: 109 | 110 | * `isPrepared()`: whether this component has been prepared and it's preparation hasn't been invalidated in the meanwhile. 111 | * `whenReady()`: returns a promise that resolves when this component has been prepared. It will wait until any current preparation is finished, and then check again (in case another preparation got scheduled in the meanwhile). This will always resolve asynchronously. Note that this only checks the preparation of the component itself, not any of its children. 112 | * `whenRendered()`: returns a promise that resolves when there are no more pending render passes (due to a `forceRender`, or because the component has never been rendered yet). Note that this might never be fulfilled in case of an unmounted component that would need rendering. 113 | * `whenReadyAndRendered()`: returns a promise that resolves when a component is ready and there are no more pending render passes. 114 | * `whenAllReady()`: returns a promise that resolves when a component and all of its (current) descendants are ready. Note that rendering a component (even though it is currently ready) might still give a pending result, since the descendants could change during rendering (with some of them being not ready yet). 115 | * `whenAllReadyAndRendered()`: returns a promise that resolves when a component and all of its descendants are ready and the component is rendered (a combination of `whenAllReady` and `whenRendered`). 116 | 117 | There is an extra method to make a component pending, regardless of its preparation: 118 | 119 | * `throwPending()`: interrupts the current render pass and puts this component into a pending state (which will either make it render using `doRenderPending`, or propagate up the render tree). *Use rarely. This API might change. And it is usually better to put anything that might cause a component to be pending into `doPrepare`, to avoid confusion.* 120 | 121 | 122 | ## Context 123 | 124 | The *context* is a sort of dictionary that is passed top-down through the render tree. For instance, it can be used to propagate option values from a component to all its descendants. 125 | 126 | Note that the context is not available before a component is actually mounted. 127 | 128 | * `getContext()`: returns the context for this component, or `null` if the component has not been mounted yet. (This method should not be overridden.) 129 | * `getContextModifications(prepareResult)`: can be overridden to return any modifications a parent wants to make to the context before that is passed on to children (in addition to `getPrepareContextModifications`). Receives the prepare result from `doPrepare`. Modifications are represented as a plain JS object. Default is to return `{}`. 130 | * `getPrepareContextModifications()`: can be overridden to return context modifications while the component is still preparing. The resulting context is passed to children that are rendered during the prepare phase of this component. These modifications are also applied when this component is ready (in addition to the modifications from `getContextModifications`). 131 | * `getModifiedContext()`: returns the context after any modifications (as returned by `getPrepareContextModifications` and `getContextModifications`) by this component. If this is called while `doPrepare` is pending, only the modifications from `getPrepareContextModifications` are applied. This method should not be overridden (override `getContextModifications` or `getPrepareContextModifications` instead). 132 | * `onReceiveContext()`: called when this component receives a new context. 133 | * `whenContextReceived()`: returns a `SyncPromise` that resolves as soon as this component receives a context for the first time (i.e. when it is mounted for the first time). 134 | 135 | The context object itself is not a plain JS object, but an instance of `Context` or a compatible class. To access individual entries, use the method `.get(name)`. A context class also needs to implement a method `.change(modifications)`, even though that should not be used directly (it is only used internally by `getModifiedContext`). 136 | 137 | The resulting modified contexts are cached and only a new context object is returned if either the outer context or the modifications change; so contexts are usually strictly identical (in the sense of `===`) unless something actually changes, and we don't call `onReceiveContext` unnecessarily. 138 | 139 | * `useContextCachesFromComponent(otherComponent)`: Reuse the context caches from another component, so that this component keeps generating the same context objects as long as the outer context and modifications stay the same. This is useful when replacing one component with another but keeping the same children, and you want to avoid that these children receive a new context unnecessarily. *Use rarely. This API might change.* 140 | 141 | ## Render cache and invalidation 142 | 143 | `render` keeps a cache of previous render results. If it's called with the same arguments again (and the context is still the same), it will return the previous result. 144 | 145 | * `forceRender()`: triggers a `needs-render` event that bubbles up the tree, invalidating render caches along the way. At the top level, `needs-render` events are batched (until the next animation frame). Consumers of a render tree need to listen to this event and re-render accordingly. 146 | * `shouldRender(arg, prepareResult)`: determines whether `doRender` should be called again if it has been called already. Default is to return `true` iff any (Backbone model) attributes changed since the last call to `doPrepare`. Note that a component will always re-render if it or any of its descendants was forced to render, which happens implicitly when the context of a component changes. 147 | * `getMaxRenderCacheSize()`: can be overridden to return the maximum entries in the render cache. Default is 1. 148 | 149 | Independently of the actual render cache, each component "remembers" its currently active render result which can be accessed using the following method: 150 | 151 | * `getRenderResult()`: returns the currently "active" render result of this component. This returns the correct result even if a component has been rendered using its render cache (which would not cause `doRender` to be called again). So this is *not* equivalent to always just remembering the last result from `render`. If this component is not currently mounted and rendered, `null` is returned. 152 | 153 | ## Attributes 154 | 155 | Backbone attributes have a special role in the component model: Whenever an attribute changes, a component will re-render (using `forceRender`), unless `shouldRender` returns `false`. 156 | 157 | They are also significant in the default implementation of `shouldPrepare`: `doPrepare` will only be called another time if attributes changed in the meanwhile. 158 | 159 | Since reacting to changes of attributes is a common operation, there is some added convenience for that: 160 | 161 | * `onChange` (`Object.`): a dictionary mapping attribute names to functions that will be called when the respective attribute changes, receiving the new attribute value as their argument. These listeners are automatically installed when the component appears, and they are uninstalled when the component disappears. Use this instead of attaching event handlers manually. 162 | * `whenAttributesHasValue(name, value)`: returns a `SyncPromise` that resolves when the attribute `name` has the given `value`. It will resolve synchronously when the attribute already has that value. 163 | * `fastSet(name, value)`: an optimized variant of Backbone's `set` (with certain limitations). It only triggers change handlers in `onChange` but no other Backbone `change` events. This saves some performance overhead. *Use rarely. This API might change in the future.* 164 | 165 | ## Events 166 | 167 | Backbone events usually propagate up the render tree. 168 | 169 | * `onChildEvent(child, name, ...args)`: called on the parent when a child fires an event. The default implementation of `onChildEvent` re-triggers the event on the parent itself (except for `change` events, which are not propagated). 170 | 171 | The `needs-render` event is handled internally and is not passed through `onChildEvent`. 172 | 173 | ## Exceptions 174 | 175 | Exceptions during a render pass are generally "swallowed" in `render`. If an error occurs, `render` will call the `doRenderError` method to render an erroneous item (which is typically defined to produce a "pink box"). 176 | 177 | This is also true for an asynchronous `doPrepare` method: If it throws an error or rejects its promise, the component transitions into an error state. 178 | -------------------------------------------------------------------------------- /test/Component.spec.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '../src/index'; 2 | import SyncPromise from 'sync-promise-js'; 3 | 4 | describe('Component', () => { 5 | Component.setScheduler( 6 | (func, delay) => { 7 | SyncPromise.defer().then(func); 8 | return [0, 0]; 9 | }, 10 | () => { 11 | // Do nothing. We don't care about cancelling schedules here. 12 | } 13 | ); 14 | 15 | it('renders', () => { 16 | class Foo extends Component { 17 | doRender() { 18 | return 1; 19 | } 20 | } 21 | 22 | const foo = new Foo(); 23 | expect(foo.renderRoot()).toEqual(1); 24 | }); 25 | 26 | it('renders children', () => { 27 | class ContainerComponent extends Component { 28 | declare child1: ChildComponent; 29 | declare child2: ChildComponent; 30 | 31 | initialize() { 32 | this.child1 = new ChildComponent(1); 33 | this.child2 = new ChildComponent(2); 34 | } 35 | 36 | doRender() { 37 | return [this.child1.render(), this.child2.render()]; 38 | } 39 | } 40 | 41 | class ChildComponent extends Component { 42 | declare content: number; 43 | 44 | initialize(content) { 45 | this.content = content; 46 | } 47 | 48 | doRender() { 49 | return this.content; 50 | } 51 | } 52 | 53 | const container = new ContainerComponent(); 54 | expect(container.renderRoot()).toEqual([1, 2]); 55 | }); 56 | 57 | describe('.renderRootAsync', () => { 58 | it('waits for asynchronous preparation', () => { 59 | class Foo extends Component { 60 | doPrepare() { 61 | return SyncPromise.defer().then(() => 1); 62 | } 63 | 64 | doRender(arg, prepareResult) { 65 | expect(prepareResult).toBe(1); 66 | return 2; 67 | } 68 | } 69 | 70 | const component = new Foo(); 71 | return Promise.resolve(component.renderRootAsync().then(result => { 72 | expect(result).toBe(2); 73 | })); 74 | }); 75 | }); 76 | 77 | describe('.unrenderRoot', () => { 78 | it('unmounts a top-level component', () => { 79 | let appearDone = false; 80 | let mountDone = false; 81 | let unmountCalled = false; 82 | 83 | class Foo extends Component { 84 | onAppear() { 85 | appearDone = true; 86 | } 87 | 88 | onMount() { 89 | expect(appearDone).toBe(true); 90 | mountDone = true; 91 | } 92 | 93 | onUnmount() { 94 | expect(mountDone).toBe(true); 95 | unmountCalled = true; 96 | } 97 | } 98 | 99 | const component = new Foo(); 100 | expect(unmountCalled).toBe(false); 101 | component.renderRoot(); 102 | component.unrenderRoot(); 103 | expect(unmountCalled).toBe(true); 104 | }); 105 | }); 106 | 107 | it('mounts prepare children again after being unmounted and rendered again', () => { 108 | class Container extends Component { 109 | preparer = new Preparer(); 110 | 111 | defaults() { 112 | return { 113 | enabled: true 114 | }; 115 | } 116 | 117 | doRender() { 118 | if (this.state.enabled) { 119 | return this.preparer.render(); 120 | } 121 | 122 | return 'nothing'; 123 | } 124 | } 125 | 126 | class Preparer extends Component { 127 | preparee = new Preparee(); 128 | 129 | doPrepare() { 130 | return this.preparee.render(); 131 | } 132 | 133 | doRender(arg, prepareResult) { 134 | return prepareResult; 135 | } 136 | } 137 | 138 | class Preparee extends Component { 139 | doRender() { 140 | return 'prepared'; 141 | } 142 | } 143 | 144 | const container = new Container(); 145 | expect(container.renderRoot()).toBe('prepared'); 146 | container.setState({ 147 | enabled: false 148 | }); 149 | expect(container.renderRoot()).toBe('nothing'); 150 | expect(container.preparer.preparee.isMounted()).toBe(false); 151 | container.setState({ 152 | enabled: true 153 | }); 154 | expect(container.renderRoot()).toBe('prepared'); 155 | expect(container.preparer.preparee.isMounted()).toBe(true); 156 | }); 157 | 158 | it('calls onMount and onUnmount', () => { 159 | class ContainerComponent extends Component { 160 | declare children: ChildComponent[]; 161 | 162 | initialize() { 163 | this.children = [new ChildComponent(), new ChildComponent()]; 164 | } 165 | 166 | doRender(which) { 167 | return this.children[which].render(); 168 | } 169 | } 170 | 171 | class ChildComponent extends Component { 172 | mountSpy = jest.fn(); 173 | unmountSpy = jest.fn(); 174 | 175 | onMount() { 176 | this.mountSpy(); 177 | } 178 | 179 | onUnmount() { 180 | this.unmountSpy(); 181 | } 182 | } 183 | 184 | const container = new ContainerComponent(); 185 | container.renderRoot(0); 186 | expect(container.children[0].mountSpy).toHaveBeenCalled(); 187 | container.renderRoot(1); 188 | expect(container.children[0].unmountSpy).toHaveBeenCalled(); 189 | expect(container.children[1].mountSpy).toHaveBeenCalled(); 190 | }); 191 | 192 | it('does not call doRender again when shouldRender returns false', () => { 193 | let renderCount = 0; 194 | 195 | class Foo extends Component { 196 | defaults() { 197 | return { 198 | foo: 1, 199 | bar: 2 200 | }; 201 | } 202 | 203 | shouldRender(changed) { 204 | return 'foo' in changed; 205 | } 206 | 207 | doRender() { 208 | ++renderCount; 209 | } 210 | } 211 | 212 | const foo = new Foo(); 213 | foo.renderRoot(); 214 | expect(renderCount).toBe(1); 215 | foo.setState({ 216 | foo: 2 217 | }); 218 | foo.renderRoot(); 219 | expect(renderCount).toBe(2); 220 | foo.setState({ 221 | bar: 3 222 | }); 223 | expect(renderCount).toBe(2); 224 | }); 225 | 226 | it('keeps rendering in error state if an error happened during initialization', () => { 227 | class ErrorComponent extends Component { 228 | initialize() { 229 | throw new Error('error'); 230 | } 231 | 232 | doRenderError(arg, error: Error) { 233 | return error.message; 234 | } 235 | } 236 | 237 | const instance = new ErrorComponent(); 238 | expect(instance.renderRoot()).toBe('error'); 239 | instance.setState({ 240 | someNewState: 42 241 | }); 242 | expect(instance.renderRoot()).toBe('error'); 243 | }); 244 | 245 | it('rerenders a component in an error state if a child requests a new render', () => { 246 | class Parent extends Component { 247 | child = new Child(); 248 | 249 | doRenderError() { 250 | return 'error'; 251 | } 252 | 253 | doRender() { 254 | const isValid = this.child.render(); 255 | 256 | if (!isValid) { 257 | throw new Error('invalid child'); 258 | } 259 | 260 | return 'valid'; 261 | } 262 | } 263 | 264 | class Child extends Component { 265 | doRender() { 266 | return this.state.isValid; 267 | } 268 | } 269 | 270 | const parent = new Parent(); 271 | expect(parent.renderRoot()).toBe('error'); 272 | parent.child.setState({ 273 | isValid: true 274 | }); 275 | expect(parent.renderRoot()).toBe('valid'); 276 | }); 277 | 278 | it('rerenders a component in an error state if a child requests a new render and renderRoot is called on an ancestor of both components', () => { 279 | class Root extends Component { 280 | containedComponent = new Parent(); 281 | 282 | doRender() { 283 | return this.containedComponent.render(); 284 | } 285 | } 286 | 287 | class Parent extends Component { 288 | child = new Child(); 289 | 290 | doRenderError() { 291 | return 'error'; 292 | } 293 | 294 | doRender() { 295 | const isValid = this.child.render(); 296 | 297 | if (!isValid) { 298 | throw new Error('invalid child'); 299 | } 300 | 301 | return 'valid'; 302 | } 303 | } 304 | 305 | class Child extends Component { 306 | doRender() { 307 | return this.state.isValid; 308 | } 309 | } 310 | 311 | const root = new Root(); 312 | expect(root.renderRoot()).toBe('error'); 313 | root.containedComponent.child.setState({ 314 | isValid: true 315 | }); 316 | expect(root.renderRoot()).toBe('valid'); 317 | }); 318 | 319 | it('rerenders a component *not* in an error state if a child requests a new render and renderRoot is called on an ancestor of both components', () => { 320 | class Root extends Component { 321 | containedComponent = new Parent(); 322 | 323 | doRender() { 324 | return this.containedComponent.render(); 325 | } 326 | } 327 | 328 | class Parent extends Component { 329 | child = new Child(); 330 | 331 | doRender() { 332 | const isValid = this.child.render(); 333 | 334 | if (!isValid) { 335 | return 'invalid'; 336 | } 337 | 338 | return 'valid'; 339 | } 340 | } 341 | 342 | class Child extends Component { 343 | doRender() { 344 | return this.state.isValid; 345 | } 346 | } 347 | 348 | const root = new Root(); 349 | expect(root.renderRoot()).toBe('invalid'); 350 | root.containedComponent.child.setState({ 351 | isValid: true 352 | }); 353 | expect(root.renderRoot()).toBe('valid'); 354 | }); 355 | 356 | it('rerenders a component in an error state if a prepare child requests a new render', () => { 357 | class Parent extends Component { 358 | child = new Child(); 359 | 360 | doRenderError() { 361 | return 'error'; 362 | } 363 | 364 | doPrepare() { 365 | const isValid = this.child.render(); 366 | 367 | if (!isValid) { 368 | throw new Error('invalid child'); 369 | } 370 | 371 | return 'valid'; 372 | } 373 | 374 | doRender(_arg, prepareResult) { 375 | return prepareResult; 376 | } 377 | } 378 | 379 | class Child extends Component { 380 | doRender() { 381 | return this.state.isValid; 382 | } 383 | } 384 | 385 | const parent = new Parent(); 386 | expect(parent.renderRoot()).toBe('error'); 387 | parent.child.setState({ 388 | isValid: true 389 | }); 390 | expect(parent.renderRoot()).toBe('valid'); 391 | }); 392 | 393 | it('does not call doPrepare again when shouldPrepare returns false', () => { 394 | let prepareCount = 0; 395 | 396 | class Foo extends Component { 397 | defaults() { 398 | return { 399 | foo: 1, 400 | bar: 2 401 | }; 402 | } 403 | 404 | shouldPrepare(changed) { 405 | return changed.has('foo'); 406 | } 407 | 408 | doPrepare() { 409 | ++prepareCount; 410 | } 411 | } 412 | 413 | const foo = new Foo(); 414 | foo.renderRoot(); 415 | expect(prepareCount).toBe(1); 416 | foo.setState({ 417 | foo: 2 418 | }); 419 | foo.renderRoot(); 420 | expect(prepareCount).toBe(2); 421 | foo.setState({ 422 | bar: 3 423 | }); 424 | expect(prepareCount).toBe(2); 425 | }); 426 | 427 | it('calls doPrepare again when a prepare child needs rendering', () => { 428 | let prepareCount = 0; 429 | 430 | class Parent extends Component { 431 | child = new Child(); 432 | 433 | doPrepare() { 434 | ++prepareCount; 435 | return this.child.render(); 436 | } 437 | } 438 | 439 | class Child extends Component {} 440 | 441 | const parent = new Parent(); 442 | Component.render(parent); 443 | expect(prepareCount).toBe(1); 444 | parent.child.forceRender(); 445 | Component.render(parent); 446 | expect(prepareCount).toBe(2); 447 | }); 448 | 449 | it('calls doRender again after the component prepares', () => { 450 | let counter = 0; 451 | 452 | class Parent extends Component { 453 | child: Child; 454 | 455 | doPrepare() { 456 | this.child = new Child(counter++); 457 | } 458 | 459 | shouldPrepare() { 460 | // This component prepares every time it renders. 461 | return true; 462 | } 463 | 464 | doRender() { 465 | return this.child.render(); 466 | } 467 | } 468 | 469 | class Child extends Component { 470 | value: number; 471 | 472 | constructor(value: number) { 473 | super(); 474 | this.value = value; 475 | } 476 | 477 | doRender() { 478 | return this.value; 479 | } 480 | } 481 | 482 | const parent = new Parent(); 483 | expect(Component.render(parent)).toBe(0); 484 | expect(Component.render(parent)).toBe(1); 485 | }); 486 | 487 | it('handles asynchronous onAppear / onMount / onUnmount / onDisappear', () => { 488 | const events: string[] = []; 489 | 490 | class ContainerComponent extends Component { 491 | declare children: ChildComponent[]; 492 | declare which: number; 493 | 494 | initialize() { 495 | this.children = [new ChildComponent('child1'), new ChildComponent('child2')]; 496 | this.which = 0; 497 | } 498 | 499 | getContextModifications() { 500 | events.push('context'); 501 | return {}; 502 | } 503 | 504 | doRender() { 505 | events.push('render'); 506 | return this.children[this.which].render(); 507 | } 508 | 509 | shouldRender() { 510 | return true; 511 | } 512 | } 513 | 514 | class ChildComponent extends Component { 515 | declare name: string; 516 | 517 | initialize(name) { 518 | this.name = name; 519 | } 520 | 521 | onAppear() { 522 | events.push(`appear ${this.name}`); 523 | } 524 | 525 | onMount() { 526 | events.push(`mount ${this.name}`); 527 | return SyncPromise.defer(); 528 | } 529 | 530 | onUnmount() { 531 | events.push(`unmount ${this.name}`); 532 | return SyncPromise.defer(); 533 | } 534 | 535 | onDisappear() { 536 | events.push(`disappear ${this.name}`); 537 | } 538 | 539 | doRender() { 540 | events.push(`render ${this.name}`); 541 | } 542 | } 543 | 544 | const container = new ContainerComponent(); 545 | container.setRenderRequestCallback(() => container.renderRoot()); 546 | container.renderRoot(); 547 | container.which = 1; 548 | container.renderRoot(); 549 | // Don't wait for `container.children[0].whenRendered()`, since that is never resolved. 550 | return Promise.resolve(SyncPromise.all([ 551 | container.whenReady(), 552 | container.whenRendered(), 553 | container.children[0].whenReady() 554 | ]).then(() => { 555 | expect(events).toEqual([ 556 | 'render', // context is received before onAppear and onMount are called 557 | 'context', 558 | 'appear child1', 559 | 'mount child1', 560 | 'render child1', 561 | 'render', 562 | 'context', 563 | 'appear child2', 564 | 'mount child2', 565 | 'render child2', 566 | 'unmount child1', 567 | 'disappear child1' 568 | ]); 569 | })); 570 | }); 571 | 572 | it('caches multiple render results', () => { 573 | let renderCallCount = 0; 574 | 575 | class Foo extends Component { 576 | doRender(arg) { 577 | ++renderCallCount; 578 | return arg; 579 | } 580 | 581 | getMaxRenderCacheSize() { 582 | return 2; 583 | } 584 | } 585 | 586 | const foo = new Foo(); 587 | const key = {}; 588 | expect(foo.renderRoot(1)).toBe(1); 589 | expect(renderCallCount).toBe(1); 590 | expect(foo.renderRoot(key)).toBe(key); 591 | expect(renderCallCount).toBe(2); 592 | expect(foo.renderRoot(1)).toBe(1); 593 | expect(foo.renderRoot(key)).toBe(key); 594 | expect(renderCallCount).toBe(2); 595 | }); 596 | 597 | it('invokes onCachedRender', () => { 598 | let cachedRenderCallCount = 0; 599 | 600 | class Foo extends Component { 601 | doRender(arg) { 602 | return arg; 603 | } 604 | 605 | onCachedRender(_cachedResult) { 606 | cachedRenderCallCount++; 607 | } 608 | } 609 | 610 | const foo = new Foo(); 611 | expect(foo.renderRoot(1)).toBe(1); 612 | expect(cachedRenderCallCount).toBe(0); 613 | expect(foo.renderRoot(1)).toBe(1); 614 | expect(cachedRenderCallCount).toBe(1); 615 | }); 616 | it('renders after rendering even if render request is made by a cached child', () => { 617 | let childRenderCount = 0; 618 | 619 | class Parent extends Component { 620 | child = new Child(); 621 | 622 | getMaxRenderCacheSize() { 623 | // Remember both render results, for `showChild` being true and false. 624 | return 2; 625 | } 626 | 627 | doRender({showChild}) { 628 | return showChild ? this.child.render() : null; 629 | } 630 | } 631 | 632 | class Child extends Component { 633 | defaults() { 634 | return { 635 | reappearCount: 0 636 | }; 637 | } 638 | 639 | onAppear() { 640 | if (childRenderCount > 0) { 641 | this.set('reappearCount', this.get('reappearCount') + 1); 642 | } 643 | } 644 | 645 | doRender() { 646 | return { 647 | renderCount: ++childRenderCount, 648 | reappearCount: this.get('reappearCount') 649 | }; 650 | } 651 | } 652 | 653 | const parent = new Parent(); 654 | parent.renderRoot({ 655 | showChild: true 656 | }); 657 | parent.renderRoot({ 658 | showChild: false 659 | }); 660 | expect(childRenderCount).toBe(1); 661 | // Render again with the child shown. This will use the parent's cache and will remount the child, 662 | // calling the child's `onAppear` handler again. That handler will change an attribute, thereby forcing 663 | // a re-render of the child. That re-render will be executed right away (thanks to the Component's 664 | // "needsRenderAfterRender" mechanism), increasing the `renderCount`. 665 | const result = parent.renderRoot({ 666 | showChild: true 667 | }); 668 | expect(childRenderCount).toBe(2); 669 | expect(result).toEqual({ 670 | renderCount: 2, 671 | reappearCount: 1 672 | }); 673 | }); 674 | it('rerenders after a state change even if there has been a render error before', () => { 675 | class ErrorThrowing extends Component { 676 | defaults() { 677 | return { 678 | shouldThrow: true 679 | }; 680 | } 681 | 682 | doRender() { 683 | if (this.state.shouldThrow) { 684 | throw new Error('error'); 685 | } else { 686 | return 'result'; 687 | } 688 | } 689 | 690 | doRenderError() { 691 | return 'error'; 692 | } 693 | } 694 | 695 | const instance = new ErrorThrowing(); 696 | expect(instance.renderRoot()).toBe('error'); 697 | instance.setState({ 698 | shouldThrow: false 699 | }); 700 | expect(instance.renderRoot()).toBe('result'); 701 | }); 702 | 703 | it('rerenders after a state change even if there has been a prepare error before', async () => { 704 | class ErrorThrowing extends Component { 705 | defaults() { 706 | return { 707 | shouldThrow: true 708 | }; 709 | } 710 | 711 | doPrepare() { 712 | return SyncPromise.defer().then(() => { 713 | if (this.state.shouldThrow) { 714 | throw new Error('error'); 715 | } else { 716 | return 'result'; 717 | } 718 | }); 719 | } 720 | 721 | doRender(arg, prepareResult) { 722 | return prepareResult; 723 | } 724 | 725 | doRenderError() { 726 | return 'error'; 727 | } 728 | } 729 | 730 | const instance = new ErrorThrowing(); 731 | expect(await Promise.resolve(instance.renderRootAsync())).toBe('error'); 732 | instance.setState({ 733 | shouldThrow: false 734 | }); 735 | expect(await Promise.resolve(instance.renderRootAsync())).toBe('result'); 736 | }); 737 | 738 | describe('context', () => { 739 | it('is passed from parent to child', () => { 740 | class Parent extends Component { 741 | getContextModifications() { 742 | return { 743 | key: 1 744 | }; 745 | } 746 | 747 | doRender(child) { 748 | return child.render(); 749 | } 750 | } 751 | 752 | class Child extends Component { 753 | doRender() { 754 | return this.getContext().get('key'); 755 | } 756 | } 757 | 758 | const parent = new Parent(); 759 | const child = new Child(); 760 | expect(parent.renderRoot(child)).toBe(1); 761 | }); 762 | 763 | it('calls .onReceiveContext when the context changes', () => { 764 | let received = 0; 765 | 766 | class Parent extends Component { 767 | defaults() { 768 | return { 769 | key: 0 770 | }; 771 | } 772 | 773 | getContextModifications() { 774 | return { 775 | key: this.state.key 776 | }; 777 | } 778 | 779 | doRender(child) { 780 | return child.render(); 781 | } 782 | } 783 | 784 | class Child extends Component { 785 | onReceiveContext() { 786 | // Count the number of invocations. 787 | ++received; 788 | } 789 | 790 | doRender() { 791 | return this.getContext().get('key'); 792 | } 793 | } 794 | 795 | const parent = new Parent(); 796 | const child = new Child(); 797 | parent.setState({ 798 | key: 41 799 | }); 800 | expect(parent.renderRoot(child)).toBe(41); 801 | expect(received).toBe(1); 802 | expect(parent.renderRoot(child)).toBe(41); 803 | expect(parent.renderRoot(child)).toBe(41); 804 | // Same context, so onReceiveContext hasn't been called another time even after multiple renders. 805 | expect(received).toBe(1); 806 | parent.setState({ 807 | key: 42 808 | }); 809 | expect(parent.renderRoot(child)).toBe(42); 810 | expect(received).toBe(2); 811 | }); 812 | 813 | xit('registers context attributes used during .onReceiveContext', () => { 814 | class Parent extends Component { 815 | defaults() { 816 | return { 817 | key: 0 818 | }; 819 | } 820 | 821 | getContextModifications() { 822 | return { 823 | key: this.state.key 824 | }; 825 | } 826 | 827 | doRender(child) { 828 | return child.render(); 829 | } 830 | } 831 | 832 | class Child extends Component { 833 | declare key: any; 834 | 835 | onReceiveContext() { 836 | this.key = this.getContext().get('key'); 837 | } 838 | 839 | doRender() { 840 | return this.key; 841 | } 842 | } 843 | 844 | const parent = new Parent(); 845 | const child = new Child(); 846 | parent.setState({ 847 | key: 41 848 | }); 849 | expect(parent.renderRoot(child)).toBe(41); 850 | // This fails, and that's why using onReceiveContext is not recommended. 851 | parent.setState({ 852 | key: 42 853 | }); 854 | expect(parent.renderRoot(child)).toBe(42); 855 | }); 856 | 857 | it('does not rerender a child when an unused context attribute changes', () => { 858 | let childRenderCount = 0; 859 | 860 | class Parent extends Component { 861 | defaults() { 862 | return { 863 | key: 0, 864 | otherKey: 0 865 | }; 866 | } 867 | 868 | getContextModifications() { 869 | return { 870 | key: this.state.key, 871 | otherKey: this.state.otherKey 872 | }; 873 | } 874 | 875 | doRender(child) { 876 | return child.render(); 877 | } 878 | } 879 | 880 | class Child extends Component { 881 | doRender() { 882 | ++childRenderCount; 883 | return this.getContext().get('key'); 884 | } 885 | } 886 | 887 | const parent = new Parent(); 888 | const child = new Child(); 889 | expect(parent.renderRoot(child)).toBe(0); 890 | expect(childRenderCount).toBe(1); 891 | parent.setState({ 892 | key: 1 893 | }); 894 | expect(parent.renderRoot(child)).toBe(1); 895 | expect(childRenderCount).toBe(2); 896 | parent.setState({ 897 | otherKey: 2 898 | }); 899 | expect(parent.renderRoot(child)).toBe(1); 900 | expect(childRenderCount).toBe(2); 901 | }); 902 | it('rerenders a previously cached child if the context changes', () => { 903 | class Top extends Component { 904 | declare child: Middle; 905 | 906 | initialize({child}) { 907 | this.child = child; 908 | } 909 | 910 | getContextModifications() { 911 | return { 912 | attr: this.state.attr 913 | }; 914 | } 915 | 916 | doRender({useContext}) { 917 | return this.child.render({ 918 | useContext 919 | }); 920 | } 921 | } 922 | 923 | class Middle extends Component { 924 | declare child: Bottom; 925 | 926 | initialize({child}) { 927 | this.child = child; 928 | } 929 | 930 | shouldPrepare() { 931 | // Force this component to prepare, 932 | // which causes its internal set of used context attributes 933 | // to be reset on each render. 934 | return true; 935 | } 936 | 937 | getMaxRenderCacheSize(): number { 938 | // Cache multiple render results, 939 | // esp. one that used the context and one that didn't. 940 | return 2; 941 | } 942 | 943 | doRender({useContext}) { 944 | return this.child.render({ 945 | useContext 946 | }); 947 | } 948 | } 949 | 950 | class Bottom extends Component { 951 | doRender({useContext}) { 952 | return useContext ? this.getContext().get('attr') : 0; 953 | } 954 | } 955 | 956 | const bottom = new Bottom(); 957 | const middle = new Middle({ 958 | child: bottom 959 | }); 960 | const top = new Top({ 961 | child: middle 962 | }); 963 | top.setState({ 964 | attr: 1 965 | }); 966 | // The first render populates the middle cache with a result 967 | // that depends on the context. 968 | expect( 969 | top.renderRoot({ 970 | useContext: true 971 | }) 972 | ).toBe(1); 973 | // The second render also populates the cache. 974 | // At that point, the middle doesn't depend on the context. 975 | expect( 976 | top.renderRoot({ 977 | useContext: false 978 | }) 979 | ).toBe(0); 980 | top.setState({ 981 | attr: 2 982 | }); 983 | // Now, the bottom component re-renders even though it could 984 | // have used its cached render result if only the current context 985 | // dependencies were taken into account. 986 | expect( 987 | top.renderRoot({ 988 | useContext: true 989 | }) 990 | ).toBe(2); 991 | }); 992 | 993 | it('registers used context attributes from cached descendants after re-preparing', () => { 994 | class ContextProvider extends Component { 995 | declare child: Top; 996 | 997 | initialize({child}) { 998 | this.child = child; 999 | } 1000 | 1001 | getContextModifications() { 1002 | return { 1003 | attr: this.state.attr 1004 | }; 1005 | } 1006 | 1007 | doRender() { 1008 | return this.child.render(); 1009 | } 1010 | } 1011 | 1012 | class Top extends Component { 1013 | declare child: Middle; 1014 | 1015 | initialize({child}) { 1016 | this.child = child; 1017 | } 1018 | 1019 | shouldPrepare() { 1020 | return true; 1021 | } 1022 | 1023 | doPrepare() { 1024 | return this.child.render(); 1025 | } 1026 | 1027 | doRender(arg, prepareResult) { 1028 | return prepareResult; 1029 | } 1030 | } 1031 | 1032 | class Middle extends Component { 1033 | declare child: Bottom; 1034 | 1035 | initialize({child}) { 1036 | this.child = child; 1037 | } 1038 | 1039 | getMaxRenderCacheSize(): number { 1040 | // Do not use a cache for middle, 1041 | // so that it rerenders every time and only uses a cache for 1042 | // bottom, from which the used context attribute will propagate 1043 | // back up to middle (but not further to top since middle 1044 | // already has it). 1045 | return 0; 1046 | } 1047 | 1048 | doRender() { 1049 | return this.child.render(); 1050 | } 1051 | } 1052 | 1053 | class Bottom extends Component { 1054 | doRender() { 1055 | return this.getContext().get('attr'); 1056 | } 1057 | } 1058 | 1059 | const bottom = new Bottom(); 1060 | const middle = new Middle({ 1061 | child: bottom 1062 | }); 1063 | const top = new Top({ 1064 | child: middle 1065 | }); 1066 | const contextProvider = new ContextProvider({ 1067 | child: top 1068 | }); 1069 | contextProvider.setState({ 1070 | attr: 1 1071 | }); 1072 | expect(contextProvider.renderRoot()).toBe(1); 1073 | // Force prepare which will reset the used context attributes of top. 1074 | top.forcePrepare(); 1075 | expect(contextProvider.renderRoot()).toBe(1); 1076 | // Now change the context attribute which should cause bottom to re-render. 1077 | contextProvider.setState({ 1078 | attr: 2 1079 | }); 1080 | expect(contextProvider.renderRoot()).toBe(2); 1081 | }); 1082 | }); 1083 | 1084 | it('.whenContextReceived returns a promise resolving when a context is available', () => { 1085 | class Parent extends Component { 1086 | doPrepare() { 1087 | return SyncPromise.defer().then(() => 'prepareResult'); 1088 | } 1089 | 1090 | getContextModifications(prepareResult) { 1091 | return { 1092 | parentPrepareResult: prepareResult 1093 | }; 1094 | } 1095 | 1096 | doRender(child) { 1097 | return child.render(); 1098 | } 1099 | 1100 | doRenderPending() { 1101 | return 'pending'; 1102 | } 1103 | } 1104 | 1105 | class Child extends Component { 1106 | doRender() { 1107 | return this.getContext().get('parentPrepareResult'); 1108 | } 1109 | } 1110 | 1111 | const parent = new Parent(); 1112 | const child = new Child(); 1113 | expect(parent.renderRoot(child)).toBe('pending'); 1114 | parent.renderRootAsync(child); 1115 | return child.whenContextReceived().then(context => { 1116 | expect(context.get('parentPrepareResult')).toBe('prepareResult'); 1117 | expect(parent.renderRoot(child)).toBe('prepareResult'); 1118 | }); 1119 | }); 1120 | 1121 | it('rendering can be interrupted and resumes at least after the previous point', () => { 1122 | class Parent extends Component { 1123 | child1 = new Child(); 1124 | child2 = new Child(); 1125 | 1126 | doRender() { 1127 | return [this.child1.render(), this.child2.render()]; 1128 | } 1129 | 1130 | doRenderPending() { 1131 | return 'pending'; 1132 | } 1133 | } 1134 | 1135 | class Child extends Component { 1136 | shouldInterruptRender() { 1137 | return true; 1138 | } 1139 | 1140 | doRender() { 1141 | return 'rendered'; 1142 | } 1143 | 1144 | doRenderPending() { 1145 | return 'pending'; 1146 | } 1147 | } 1148 | 1149 | const c = new Parent(); 1150 | expect(Component.render(c)).toEqual(['pending', 'pending']); 1151 | expect(Component.render(c)).toEqual(['rendered', 'pending']); 1152 | expect(Component.render(c)).toEqual(['rendered', 'rendered']); 1153 | }); 1154 | 1155 | it('can be used to implement a (dummy) notebook/cell/box model', () => { 1156 | class Options extends Component { 1157 | declare values: any; 1158 | declare options: any; 1159 | 1160 | initialize(values) { 1161 | // This is a simplified Options variant that only takes a single dictionary 1162 | // of option values already extracted from an expression. 1163 | this.values = values; 1164 | this.options = {}; 1165 | 1166 | Object.entries(this.values).forEach(([name, value]) => { 1167 | const OptionClass = BaseOption.getClass(name); 1168 | this.options[name] = new OptionClass(value); 1169 | }); 1170 | } 1171 | 1172 | doRender() { 1173 | const resolvedValues = {}; 1174 | const result = { 1175 | getResolvedValue(name) { 1176 | return resolvedValues[name]; 1177 | } 1178 | }; 1179 | 1180 | Object.entries(this.options).forEach(([name, option]: [string, any]) => { 1181 | result[name] = option.renderRequired(); 1182 | resolvedValues[name] = option.getResolvedValue(); 1183 | }); 1184 | 1185 | return result; 1186 | } 1187 | } 1188 | 1189 | class BaseOption extends Component<{setting: any}, void, any, {}> { 1190 | /* 1191 | An option starts with the original expression (which is explicitly given in a box or cell) 1192 | as its `originalValue`. 1193 | Whenever it receives a context from its parent, it sets the attribute `effectiveValue` 1194 | to either the `originalValue` (if given) or the value inherited from the parent. 1195 | When the `effectiveValue` changes, a `DynamicValue` instance is created if necessary. 1196 | The actual processing of the value (which is still a raw expression) happens in 1197 | `doPrepare` (which can execute asynchronously). 1198 | Once `doPrepare` resolves, "rendering" an option simply returns the processed setting. 1199 | In short: 1200 | originalValue 1201 | -> effectiveValue 1202 | -- (resolve any Dynamic) 1203 | -> resolvedValue 1204 | -- (process or processAsync) 1205 | -> setting 1206 | -> result of render 1207 | */ 1208 | onChange = { 1209 | effectiveValue(value) { 1210 | // When the effectiveValue attribute changes, create a DynamicValue as necessary. 1211 | // Note that we don't have to worry about cleaning up a previous DynamicValue here 1212 | // -- it will just end its lifecycle automatically by not being rendered anymore. 1213 | if (value && value.Dynamic) { 1214 | this.dynamic = new DynamicValue(value); 1215 | } else { 1216 | this.dynamic = null; 1217 | } 1218 | } 1219 | }; 1220 | 1221 | declare originalValue: any; 1222 | declare dynamic: DynamicValue | null; 1223 | declare name: string; 1224 | declare resolvedValue: any; 1225 | 1226 | declare static optionName; 1227 | 1228 | static getClass(name) { 1229 | return { 1230 | OptionA, 1231 | OptionB 1232 | }[name]; 1233 | } 1234 | 1235 | initialize(originalValue) { 1236 | this.originalValue = originalValue; 1237 | this.dynamic = null; 1238 | this.name = (this.constructor as typeof BaseOption).optionName; 1239 | } 1240 | 1241 | defaults() { 1242 | return { 1243 | effectiveValue: null 1244 | }; 1245 | } 1246 | 1247 | onReceiveContext() { 1248 | // When the context changes, update the effectiveValue of the option 1249 | // (in case it relied on the inherited value). 1250 | this.set('effectiveValue', this.originalValue || this.getContext().get(this.name)); 1251 | } 1252 | 1253 | doPrepare() { 1254 | // Either use a Dynamic's resolved value or the effective value of this option. 1255 | this.resolvedValue = this.dynamic ? this.dynamic.renderRequired() : this.get('effectiveValue'); 1256 | return this.processAsync(this.resolvedValue).then(setting => { 1257 | return { 1258 | setting 1259 | }; 1260 | }); 1261 | } 1262 | 1263 | getResolvedValue() { 1264 | return this.resolvedValue; 1265 | } 1266 | 1267 | doRender(arg, {setting}) { 1268 | return setting; 1269 | } 1270 | 1271 | process(value) { 1272 | // Default implementation is to return the original value. 1273 | return value; 1274 | } 1275 | 1276 | processAsync(value) { 1277 | // Default implementation is to process synchronously 1278 | // (but individual options can override processAsync to define an asynchronous resolution). 1279 | return SyncPromise.resolve(this.process(value)); 1280 | } 1281 | } 1282 | 1283 | class OptionA extends BaseOption { 1284 | static optionName = 'OptionA'; 1285 | 1286 | process(value) { 1287 | return value; 1288 | } 1289 | } 1290 | 1291 | class OptionB extends BaseOption { 1292 | static optionName = 'OptionB'; 1293 | 1294 | processAsync(value) { 1295 | // Simulate asynchronous option resolution with an artificial delay. 1296 | // Consumers of this option will only be ready once this is resolved 1297 | // (assuming they are using options via `renderRequired`). 1298 | return SyncPromise.defer().then(() => { 1299 | return `${value} processed`; 1300 | }); 1301 | } 1302 | } 1303 | 1304 | class DynamicValue extends Component { 1305 | declare dynamicExpr: any; 1306 | declare value: any; 1307 | 1308 | initialize(dynamicExpr) { 1309 | // Here, dynamicExpr isn't really an MExpr, but a JSON object of the form `{Dynamic: ...}`. 1310 | this.dynamicExpr = dynamicExpr; 1311 | this.value = null; 1312 | } 1313 | 1314 | onAppear() { 1315 | // Here we would make the initial kernel evaluation to fetch the current value, 1316 | // and install a listener for future changes (which would call forceRender). 1317 | } 1318 | 1319 | doPrepare() { 1320 | return SyncPromise.defer().then(() => { 1321 | // Dummy implementation that simply extracts the 'Dynamic' field from the given "expr". 1322 | this.value = this.dynamicExpr.Dynamic; 1323 | }); 1324 | } 1325 | 1326 | onDisappear() { 1327 | // Here we would uninstall the listener for changes. 1328 | } 1329 | 1330 | doRender() { 1331 | // DynamicValue "renders" as its resolved value. 1332 | return this.value; 1333 | } 1334 | } 1335 | 1336 | class Notebook extends Component<{options: any}> { 1337 | declare options: Options; 1338 | declare cells: Cell[]; 1339 | 1340 | initialize() { 1341 | // This would create options and cells based on the actual notebook data. 1342 | // For simplicity, we don't deal with cell groups here. 1343 | this.options = new Options({ 1344 | OptionB: { 1345 | Dynamic: 'B' 1346 | } 1347 | }); 1348 | this.cells = [new Cell('content')]; 1349 | } 1350 | 1351 | doPrepare() { 1352 | return { 1353 | options: this.options.renderRequired() 1354 | }; 1355 | } 1356 | 1357 | doRender() { 1358 | // Dummy rendering that simply returns an array of the rendered cells. 1359 | // Here we would call the NotebookView's render method, 1360 | // which would return some React element (+ other information). 1361 | return this.cells.map(cell => cell.render()); 1362 | } 1363 | 1364 | doRenderPending() { 1365 | return 'pending'; 1366 | } 1367 | 1368 | getContextModifications({options}) { 1369 | // This receives the options processed in doPrepare, and modifies the context 1370 | // that children (cells) receive accordingly. 1371 | // Here, we only pass on the resolved value of OptionB (i.e. after resolution of Dynamic, 1372 | // but before any other option processing). 1373 | return { 1374 | notebook: this, 1375 | OptionB: options ? options.getResolvedValue('OptionB') : undefined 1376 | }; 1377 | } 1378 | } 1379 | 1380 | class Cell extends Component { 1381 | declare box: Box; 1382 | 1383 | initialize(content) { 1384 | // This would create an actual box tree using `Box.fromExpr`. 1385 | // Box creation might even happen lazily in `doRender`. 1386 | // For simplicity, we don't deal with options here 1387 | // (so the Cell will only pass down the context it received from the Notebook). 1388 | this.box = new Box(content); 1389 | } 1390 | 1391 | doRender() { 1392 | return this.box.render(); 1393 | } 1394 | } 1395 | 1396 | class Box extends Component<{options: any}> { 1397 | declare options: Options; 1398 | declare content: any; 1399 | 1400 | initialize(content) { 1401 | // This would create options and initialize the box based on the actual box expression. 1402 | this.options = new Options({ 1403 | OptionA: { 1404 | Dynamic: 'A' 1405 | }, 1406 | OptionB: null 1407 | }); 1408 | this.content = content; 1409 | } 1410 | 1411 | doPrepare() { 1412 | return { 1413 | options: this.options.renderRequired() 1414 | }; 1415 | } 1416 | 1417 | doRender(arg, {options}) { 1418 | // Dummy rendering that simply returns the simple box content string 1419 | // and the literal (resolved) option settings. 1420 | return `${this.content} (${options.OptionA}, ${options.OptionB})`; 1421 | } 1422 | 1423 | doRenderPending() { 1424 | return 'pending'; 1425 | } 1426 | } 1427 | 1428 | const notebook = new Notebook(); 1429 | // Note that we don't listen to render requests on the notebook, 1430 | // in order to precisely test individual render phases. 1431 | // Even though a component is reported as ready, rendering it can still produce a pending result. 1432 | // (See the documentation of `whenAllReady`.) 1433 | // Notebook options are not ready at first, so the whole notebook renders as pending. 1434 | expect(notebook.renderRoot()).toEqual('pending'); 1435 | return notebook 1436 | .whenAllReady() 1437 | .then(() => { 1438 | // First, the Dynamic in the Notebook's OptionB is resolved. 1439 | // OptionB's processing is still pending, so the whole notebook is pending. 1440 | expect(notebook.renderRoot()).toEqual('pending'); 1441 | return notebook.whenAllReady(); 1442 | }) 1443 | .then(() => { 1444 | // Now, OptionB is fully processed, so the Notebook renders. 1445 | // But the Cell is rendered as pending since its Box is not ready yet. 1446 | expect(notebook.renderRoot()).toEqual(['pending']); 1447 | return notebook.whenAllReady(); 1448 | }) 1449 | .then(() => { 1450 | // Again, the Box's Dynamic is resolved first, but not its OptionB yet. 1451 | expect(notebook.renderRoot()).toEqual(['pending']); 1452 | return notebook.whenAllReady(); 1453 | }) 1454 | .then(() => { 1455 | // Now the Box is fully ready. 1456 | expect(notebook.renderRoot()).toEqual(['content (A, B processed)']); 1457 | }); 1458 | }); 1459 | 1460 | it('can be used to implement a box editor', () => { 1461 | /* 1462 | The editor is a bit tricky because we need to deal with boxes in two different "phases": 1463 | 1. Original boxes are coming from the notebook or they are created by the kernel. 1464 | These boxes are "linearized" into a form that's suitable for editing. 1465 | Especially, style runs are flattened out, BasicBoxes become editable text, 1466 | and most other boxes become "atomic" (non-editable) content embedded in the editor. 1467 | 2. The editor renders content by turning it into boxes again. 1468 | Each line has its own EditorLine component with boxes as its children. 1469 | This can happen outside a regular render pass of the Editor component, 1470 | because CodeMirror manages typing and calls `renderLine` directly (without starting 1471 | at the root component, i.e. the notebook). (We might change that in the future, but it's 1472 | easier to keep it like that for now.) 1473 | To keep things consistent, we render *all* editor lines as root components, 1474 | i.e. they won't have the Editor as their formal parent. 1475 | Consequently, we need to manage the lifecycle of these lines ourselves: 1476 | They need to be unrendered when the editor disappears, and any context changes need to be 1477 | manually propagated to the editor lines. 1478 | */ 1479 | class Box extends Component<{options: any}, {linearize: boolean}> { 1480 | linearize() { 1481 | // TODO: This should probably not render as a root, but then asynchronicity gets more complicated. 1482 | return this.renderRootAsync({ 1483 | linearize: true 1484 | }); 1485 | } 1486 | 1487 | doRender({linearize}, prepareResult) { 1488 | if (linearize) { 1489 | return this.doLinearize(prepareResult); 1490 | } 1491 | return null; 1492 | } 1493 | 1494 | doLinearize(opts: any): any { 1495 | return [ 1496 | { 1497 | box: this 1498 | } 1499 | ]; 1500 | } 1501 | } 1502 | 1503 | class StyleBox extends Box { 1504 | declare content: BasicBox; 1505 | 1506 | initialize() { 1507 | this.content = new BasicBox('test'); 1508 | } 1509 | 1510 | doPrepare() { 1511 | // This would use a real Options mechanism. 1512 | return SyncPromise.defer().then(() => { 1513 | return { 1514 | FontSize: 12 1515 | }; 1516 | }); 1517 | } 1518 | 1519 | getContextModifications(prepareResult) { 1520 | return prepareResult; 1521 | } 1522 | 1523 | doLinearize() { 1524 | return this.content.linearize(); 1525 | } 1526 | } 1527 | 1528 | class BasicBox extends Box { 1529 | declare text: string; 1530 | 1531 | initialize(text) { 1532 | this.text = text; 1533 | } 1534 | 1535 | doPrepare() { 1536 | // This would use a real Options mechanism. 1537 | const options = { 1538 | FontSize: this.getContext().get('FontSize') 1539 | }; 1540 | return SyncPromise.defer().then(() => { 1541 | return { 1542 | options 1543 | }; 1544 | }); 1545 | } 1546 | 1547 | doLinearize({options}) { 1548 | return [ 1549 | { 1550 | text: this.text, 1551 | fontSize: options.FontSize 1552 | } 1553 | ]; 1554 | } 1555 | 1556 | doRender({linearize}, prepareResult) { 1557 | if (linearize) { 1558 | return super.doRender( 1559 | { 1560 | linearize 1561 | }, 1562 | prepareResult 1563 | ); 1564 | } 1565 | 1566 | return this.text; 1567 | } 1568 | 1569 | doRenderPending() { 1570 | return 'box pending'; 1571 | } 1572 | } 1573 | 1574 | class EditorLine extends Component { 1575 | doRender({boxes}) { 1576 | // This would return a DOM node. 1577 | return boxes.map(box => box.render({})).join(' '); 1578 | } 1579 | 1580 | doRenderPending() { 1581 | return 'pending'; 1582 | } 1583 | 1584 | shouldWaitForChildren() { 1585 | return true; 1586 | } 1587 | } 1588 | 1589 | class Editor extends Component<{linearized: any}, any, any, {box: any}> { 1590 | declare cm: CodeMirror | null; 1591 | declare node: any; 1592 | declare linearized: any; 1593 | 1594 | renderLine = line => { 1595 | // Render an editor line by creating editor line components for each item. 1596 | // We cache the created components in the original line item. 1597 | // (This is important so that we don't constantly create -- and potentially prepare -- 1598 | // line components.) 1599 | if (!line.component) { 1600 | line.component = new EditorLine(); 1601 | line.component.setRenderRequestCallback(() => { 1602 | // Should perform a more granular refresh of this particular line. 1603 | this.cm!.refresh(); 1604 | }); 1605 | } 1606 | 1607 | const boxes: Box[] = []; 1608 | line.items.forEach(item => { 1609 | let box: Box | null = null; 1610 | 1611 | if (item.box) { 1612 | box = item.box; 1613 | } else if (item.text) { 1614 | box = item.box = new BasicBox(item.text); 1615 | } 1616 | 1617 | if (box) { 1618 | boxes.push(box); 1619 | } 1620 | }); 1621 | return line.component.renderRoot( 1622 | { 1623 | boxes 1624 | }, 1625 | { 1626 | context: this.getModifiedContext() 1627 | } 1628 | ); 1629 | }; 1630 | 1631 | unrenderLine = line => { 1632 | if (line.component) { 1633 | line.component.unrenderRoot(); 1634 | } 1635 | }; 1636 | 1637 | onEditorRefresh = () => { 1638 | // When CodeMirror repaints, re-render the Editor as well (to potentially update its dimensions). 1639 | this.forceRender(); 1640 | }; 1641 | 1642 | initialize(box) { 1643 | this.setState({box}); 1644 | this.cm = null; 1645 | this.node = {}; 1646 | this.linearized = null; 1647 | } 1648 | 1649 | shouldWaitForChildren() { 1650 | // Editor is pending as long as any of its children is pending. 1651 | // Note that only the original box is a child of the Editor, 1652 | // not the editor boxes that are constructed during rendering. 1653 | return true; 1654 | } 1655 | 1656 | doPrepare() { 1657 | // Linearize the box (which is an asynchronous operation) and return it 1658 | // as the prepareResult which gets passed to `doRender`. 1659 | return this.state.box.linearize().then(linearized => { 1660 | return { 1661 | linearized 1662 | }; 1663 | }); 1664 | } 1665 | 1666 | getContextModifications() { 1667 | return { 1668 | editor: this 1669 | }; 1670 | } 1671 | 1672 | doRender(arg, {linearized}) { 1673 | // Note that unless the (linearized) content changes, this does not 1674 | // render any child boxes. Editor boxes are not actually children of the Editor. 1675 | if (!this.cm || linearized !== this.linearized) { 1676 | if (this.cm) { 1677 | this.cm.setContent(linearized); 1678 | } else { 1679 | this.cm = new CodeMirror({ 1680 | node: this.node, 1681 | content: linearized, 1682 | renderLine: this.renderLine, 1683 | unrenderLine: this.unrenderLine, 1684 | onRefresh: this.onEditorRefresh 1685 | }); 1686 | } 1687 | 1688 | this.linearized = linearized; 1689 | } 1690 | 1691 | return this.node; 1692 | } 1693 | 1694 | eachLine(callback) { 1695 | // Iterate over the editor line components. 1696 | this.cm!.eachLine(line => { 1697 | if (line.component) { 1698 | callback(line.component); 1699 | } 1700 | }); 1701 | } 1702 | 1703 | mapLines(callback) { 1704 | const result: any[] = []; 1705 | this.eachLine(line => { 1706 | result.push(callback(line)); 1707 | }); 1708 | return result; 1709 | } 1710 | 1711 | whenLinesReadyAndRendered() { 1712 | return SyncPromise.all(this.mapLines(line => line.whenAllReadyAndRendered())); 1713 | } 1714 | 1715 | onDisappear() { 1716 | // When the editor disappears, unrender all editor lines. 1717 | this.cm!.unrender(); 1718 | } 1719 | } 1720 | 1721 | // Dummy CodeMirror implementation that renders into a given "node" (just a plain object here). 1722 | // It can mutate its own content and fires an `onRefresh` event whenever it re-renders. 1723 | class CodeMirror { 1724 | declare node: any; 1725 | declare lines: any[]; 1726 | declare renderLine: any; 1727 | declare unrenderLine: any; 1728 | declare onRefresh: any; 1729 | 1730 | constructor({node, content, renderLine, unrenderLine, onRefresh}) { 1731 | this.node = node; 1732 | this.lines = []; 1733 | this.renderLine = renderLine; 1734 | this.unrenderLine = unrenderLine; 1735 | this.onRefresh = onRefresh; 1736 | this.setContent(content); 1737 | } 1738 | 1739 | refresh() { 1740 | // In practice, editor refreshes will be more granular, and only re-render updated lines. 1741 | this.node.content = this.lines.map(this.renderLine); 1742 | this.onRefresh(); 1743 | } 1744 | 1745 | setContent(content) { 1746 | this.lines.forEach(this.unrenderLine); 1747 | // Only create a single line for simplicity here. 1748 | this.lines = [ 1749 | { 1750 | items: content 1751 | } 1752 | ]; 1753 | this.refresh(); 1754 | } 1755 | 1756 | addContent(text) { 1757 | // This is more or less what would happen on typing. 1758 | // this.content.push({text: 'foo'}); 1759 | this.lines[0].items.push({ 1760 | text 1761 | }); 1762 | this.refresh(); 1763 | } 1764 | 1765 | eachLine(callback) { 1766 | this.lines.forEach(callback); 1767 | } 1768 | 1769 | unrender() { 1770 | this.lines.forEach(this.unrenderLine); 1771 | } 1772 | } 1773 | 1774 | const box = new StyleBox(); 1775 | const editor = new Editor(box); 1776 | // `renderRootAsync` waits for the original boxes to be ready (since Editor waits for its children). 1777 | return Promise.resolve(editor 1778 | .renderRootAsync() 1779 | .then(() => { 1780 | // But the editor lines need their own preparation, so the line will render as pending initially. 1781 | const result = editor.renderRoot(); 1782 | expect(result).toEqual({ 1783 | content: ['pending'] 1784 | }); 1785 | return editor.whenLinesReadyAndRendered(); 1786 | }) 1787 | .then(() => { 1788 | // Render once again (not totally sure yet why this is necessary). 1789 | editor.renderRoot(); 1790 | return editor.whenLinesReadyAndRendered(); 1791 | }) 1792 | .then(() => { 1793 | // When the line is ready and rendered, it displays its text. 1794 | const result = editor.renderRoot(); 1795 | expect(result).toEqual({ 1796 | content: ['test'] 1797 | }); 1798 | // Add content to the editor (like typing would). 1799 | editor.cm!.addContent('foo'); 1800 | }) 1801 | .then(() => { 1802 | // Because of the line changed, it is pending again. 1803 | const result = editor.renderRoot(); 1804 | expect(result).toEqual({ 1805 | content: ['pending'] 1806 | }); 1807 | return editor.whenLinesReadyAndRendered(); 1808 | }) 1809 | .then(() => { 1810 | // Finally, the line renders with the added text. 1811 | const result = editor.renderRoot(); 1812 | expect(result).toEqual({ 1813 | content: ['test foo'] 1814 | }); 1815 | })); 1816 | }); 1817 | }); 1818 | -------------------------------------------------------------------------------- /src/Component.ts: -------------------------------------------------------------------------------- 1 | /*eslint no-underscore-dangle: "off", react/no-is-mounted: "off" */ 2 | 3 | import globals, {now, DEBUG, DEBUG_REBACK, PROFILE_REBACK, TESTING} from './globals'; 4 | 5 | import {getLogger} from 'loggers-js'; 6 | 7 | import SyncPromise from 'sync-promise-js'; 8 | 9 | import PromiseChain from './PromiseChain'; 10 | import RenderPending from './RenderPending'; 11 | import {sameShallow, applyModificationsCached, compareMaps, d} from './util'; 12 | import {start as startTiming, end as stopTiming} from './profiling'; 13 | import Cache from './Cache'; 14 | import HashCache from './HashCache'; 15 | import emptyContext from './EmptyContext'; 16 | import {addUsedContextAttributes, anyUsedAttribute} from './Context'; 17 | import SingleEntryCache from './SingleEntryCache'; 18 | 19 | import type Context from './Context'; 20 | 21 | const logger = getLogger('reback'); 22 | 23 | /** 24 | * ID for a scheduled task, which could be regular timeout or a requested animation frame (or both, theoretically). 25 | * This ID can be used to cancel the task. 26 | * A value of 0 in either slot means that the respective type of scheduling has not been used. 27 | */ 28 | type ScheduleID = [ReturnType | 0, ReturnType | 0]; 29 | 30 | /** 31 | * Time (in milliseconds) to wait before firing a render request on a root component. 32 | * Subsequent render requests in that time frame will be batched, and only a single request will ultimately 33 | * be fired. 34 | * A value of -1 means to wait for the next animation frame if the page is visible, 35 | * or use a timeout of 100 ms if the page is hidden. 36 | */ 37 | const RENDER_BATCH_TIME = -1; 38 | 39 | const EMPTY_SET: ReadonlySet = new Set(); 40 | 41 | const Phase = { 42 | CREATING: 0, 43 | MOUNTING: 1, 44 | PREPARING: 2, 45 | RENDERING: 3, 46 | RENDERED: 4, 47 | UNMOUNTING: 5, 48 | RESTORING: 6 49 | }; 50 | const PHASE_NAMES = {}; 51 | Object.keys(Phase).forEach(name => { 52 | const value = Phase[name]; 53 | PHASE_NAMES[value] = name; 54 | }); 55 | const BITS_PHASE = 3; 56 | const MASK_PHASE = 0b111; 57 | 58 | const FLAGS_OFFSET = BITS_PHASE; 59 | const FLAG_MOUNTED = 1 << FLAGS_OFFSET; 60 | const FLAG_PREPARED = 1 << (FLAGS_OFFSET + 1); 61 | const FLAG_KEPT_MOUNTED = 1 << (FLAGS_OFFSET + 2); 62 | const FLAG_AVOID_RENDER_AFTER_RENDER = 1 << (FLAGS_OFFSET + 3); 63 | const FLAG_NEEDS_RENDER_AFTER_RENDER = 1 << (FLAGS_OFFSET + 4); 64 | const FLAG_NEEDS_PREPARE_AFTER_PREPARE = 1 << (FLAGS_OFFSET + 5); 65 | const FLAG_ERROR_DURING_INITIALIZE = 1 << (FLAGS_OFFSET + 6); 66 | const FLAG_RENDER_ROOT_WAS_INTERRUPTED = 1 << (FLAGS_OFFSET + 7); 67 | 68 | function setPhaseFlags(flags: number, phase: number): number { 69 | return (flags & ~MASK_PHASE) | phase; 70 | } 71 | 72 | /** 73 | * Singleton object to indicate an interrupted render result. 74 | */ 75 | const RENDER_INTERRUPT = {}; 76 | 77 | let idCounter = 0; 78 | 79 | export type AnyComponent = Component; 80 | 81 | type RenderStateSnapshot = any[]; 82 | 83 | /** 84 | * Stack of currently rendering components. 85 | * The root component is at `renderStack[0]`. 86 | * This stack cannot be accessed directly from other modules; they need to use 87 | * `getCurrentRenderRoot`, `getCurrentRenderParent`, `pushToRenderStack`, and `popFromRenderStack` 88 | * instead. 89 | */ 90 | let renderStack: AnyComponent[] = []; 91 | let renderStackData: AnyInternalData[] = []; 92 | 93 | const renderState = { 94 | /** 95 | * Whether a render pass is currently happening. 96 | */ 97 | isRendering: false, 98 | 99 | /** 100 | * Whether the current render pass has been interrupted. 101 | */ 102 | isRenderInterrupted: false, 103 | 104 | /** 105 | * Whether the previous render pass was interrupted. 106 | */ 107 | lastRenderWasInterrupted: false, 108 | 109 | /** 110 | * Number of non-interrupted components rendered in the previous render pass. 111 | */ 112 | lastRenderComponentCount: 0, 113 | 114 | /** 115 | * Start time of this render pass (in milliseconds after some epoch). 116 | * Only differences of times are really meaningful. 117 | */ 118 | renderStartTime: 0, 119 | 120 | /** 121 | * Number of interrupted render passes before this render pass. 122 | */ 123 | renderInterruptGeneration: 0, 124 | 125 | /** 126 | * Number of non-interrupted components rendered in this render pass. 127 | */ 128 | renderComponentCount: 0 129 | }; 130 | 131 | function getCurrentRenderRoot(): AnyComponent | null { 132 | if (renderStack.length) { 133 | return renderStack[0]; 134 | } else { 135 | return null; 136 | } 137 | } 138 | 139 | function getCurrentRenderRootData(): AnyInternalData | null { 140 | if (renderStackData.length) { 141 | return renderStackData[0]; 142 | } else { 143 | return null; 144 | } 145 | } 146 | 147 | function getCurrentRenderParent(): AnyComponent | null { 148 | if (renderStack.length) { 149 | return renderStack[renderStack.length - 1]; 150 | } else { 151 | return null; 152 | } 153 | } 154 | 155 | function getCurrentRenderParentData(): AnyInternalData | null { 156 | if (renderStackData.length) { 157 | return renderStackData[renderStackData.length - 1]; 158 | } else { 159 | return null; 160 | } 161 | } 162 | 163 | function pushToRenderStack(component: AnyComponent, data: AnyInternalData): void { 164 | renderStack.push(component); 165 | renderStackData.push(data); 166 | } 167 | 168 | function popFromRenderStack(): void { 169 | renderStack.pop(); 170 | renderStackData.pop(); 171 | } 172 | 173 | /** 174 | * Resets the render state for rendering a new root component. 175 | * Returns a data structure that can be used to restore the old state, 176 | * when rendering of the new root component is finished. 177 | * This includes data for the current render stack, even though it's technically 178 | * not part of the `renderState`. 179 | */ 180 | function resetState(): RenderStateSnapshot { 181 | const oldState = [ 182 | renderState.isRendering, 183 | renderState.isRenderInterrupted, 184 | renderState.lastRenderWasInterrupted, 185 | renderState.lastRenderComponentCount, 186 | renderState.renderStartTime, 187 | renderState.renderInterruptGeneration, 188 | renderState.renderComponentCount, 189 | renderStack.slice(0), 190 | renderStackData.slice(0) 191 | ]; 192 | renderState.isRendering = false; 193 | renderState.isRenderInterrupted = false; 194 | renderState.lastRenderWasInterrupted = false; 195 | renderState.lastRenderComponentCount = 0; 196 | renderState.renderComponentCount = 0; 197 | renderStack = []; 198 | renderStackData = []; 199 | return oldState; 200 | } 201 | 202 | /** 203 | * Restores a previous render state. 204 | */ 205 | function restoreState(oldState: RenderStateSnapshot): void { 206 | renderState.isRendering = oldState[0]; 207 | renderState.isRenderInterrupted = oldState[1]; 208 | renderState.lastRenderWasInterrupted = oldState[2]; 209 | renderState.lastRenderComponentCount = oldState[3]; 210 | renderState.renderStartTime = oldState[4]; 211 | renderState.renderInterruptGeneration = oldState[5]; 212 | renderState.renderComponentCount = oldState[6]; 213 | renderStack = oldState[7]; 214 | renderStackData = oldState[8]; 215 | } 216 | 217 | function didContextChange( 218 | newContext?: Context | null, 219 | prevContext?: Context | null, 220 | usedAttributes?: Array 221 | ): boolean { 222 | if (!newContext || !usedAttributes || newContext === prevContext) { 223 | return false; 224 | } 225 | const context = newContext; 226 | return anyUsedAttribute(usedAttributes, name => { 227 | return !prevContext || !context.sameValue(prevContext.attributes.get(name), context.attributes.get(name), name); 228 | }); 229 | } 230 | 231 | type InterruptedItem = {root: AnyComponent; rootData: AnyInternalData; interrupted: AnyInternalData[]}; 232 | 233 | /** 234 | * Components that have been interrupted in the previous render pass, 235 | * as a map from root component IDs to an array of interrupted components. 236 | * After a little pause or on the next render pass of a root component (whichever comes first), 237 | * all corresponding components are re-rendered. 238 | * We choose a map so that we don't need a property on every single component 239 | * (even non-root components). 240 | */ 241 | const interruptedComponents: Map = new Map(); 242 | 243 | /** 244 | * Timeout for re-rendering previously interrupted components. 245 | */ 246 | let rerenderInterruptedTimeout: ScheduleID | null = null; 247 | 248 | /** 249 | * Components that have been unmounted recently. 250 | */ 251 | const unmountedComponents: AnyComponent[] = []; 252 | 253 | /** 254 | * Timeout for calling `onDisappear` on unmounted components. 255 | */ 256 | let disappearHandlersTimeout: ScheduleID | null = null; 257 | 258 | export type RenderOptions = { 259 | context?: ContextType; 260 | isRequired?: boolean; 261 | isOptional?: boolean; 262 | }; 263 | 264 | type ID = number; 265 | 266 | type CacheEntry = { 267 | result: RenderResult; 268 | instance: AnyComponent; 269 | children: Map | null; 270 | context: Context | null; 271 | usedContextAttributes: number[]; 272 | descendantCount: number; 273 | }; 274 | 275 | // Information for each child (stored on the parent) has the same structure as render cache entries, 276 | // since we need to be able to restore `renderedChildren` from the render cache (and, vice-versa, we construct the 277 | // child information in the render cache from `renderedChildren`). 278 | type ChildInfo = CacheEntry; 279 | type Children = Map; 280 | 281 | function iterChildren(children: Children, callback: (c: AnyComponent, id: ID) => void) { 282 | children.forEach((value, key) => { 283 | callback(value.instance, key); 284 | }); 285 | } 286 | 287 | function allChildren(children: Children, callback: (instance: any, id: ID) => boolean) { 288 | for (const [key, value] of children) { 289 | if (!callback(value.instance, key)) { 290 | return false; 291 | } 292 | } 293 | return true; 294 | } 295 | 296 | type SchedulerFunc = (func: () => void, delay?: number) => ScheduleID; 297 | type CancelScheduleFunc = (id: ScheduleID) => void; 298 | 299 | let scheduler: SchedulerFunc = (func: () => void, delay = 0): ScheduleID => { 300 | if (delay === -1) { 301 | // If requestAnimationFrame is not available and during testing, fall back to setTimeout 302 | // (assuming ~60 fps). 303 | if (globals.requestAnimationFrame && !TESTING) { 304 | // If the page is hidden, requestAnimationFrame does not fire, so we use a timeout instead (see CLOUD-15123). 305 | // Browsers might also throttle timeouts for background windows, but it's okay 306 | // for background notebooks to load more slowly, as long as they load eventually. 307 | // See https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API#Policies_in_place_to_aid_background_page_performance 308 | // for more information. 309 | if (globals.document && globals.document.hidden) { 310 | return [setTimeout(func, 100), 0]; 311 | } else { 312 | return [0, requestAnimationFrame(func)]; 313 | } 314 | } else { 315 | return [setTimeout(func, 16), 0]; 316 | } 317 | } 318 | return [setTimeout(func, delay), 0]; 319 | }; 320 | 321 | let cancelSchedule: CancelScheduleFunc = ([timeoutID, animationFrameID]: ScheduleID) => { 322 | if (timeoutID) { 323 | clearTimeout(timeoutID); 324 | } 325 | if (animationFrameID && globals.cancelAnimationFrame) { 326 | cancelAnimationFrame(animationFrameID); 327 | } 328 | }; 329 | 330 | function _rerenderInterrupted(rootId: ID) { 331 | const item = interruptedComponents.get(rootId); 332 | if (item) { 333 | const interrupted = item.interrupted; 334 | for (let i = 0, l = interrupted.length; i < l; ++i) { 335 | let data: AnyInternalData | void = interrupted[i]; 336 | let invalidatePrepare = false; 337 | while (data) { 338 | // We only invalidate the render cache here and don't call `forceRender`, 339 | // since that would bubble up to the root and schedule another render pass. 340 | // But we might already be in a render pass (this is called from `renderRoot`), 341 | // and then that would be unnecessary. 342 | if (invalidatePrepare) { 343 | _invalidatePrepareCache(data); 344 | } 345 | _invalidateRenderCache(data); 346 | const phase = data.flags & MASK_PHASE; 347 | if (phase !== Phase.RENDERED && phase !== Phase.RESTORING) { 348 | // If the component is currently mounting, preparing, or unmounting, it doesn't need to bubble 349 | // up a cache invalidation (similarly to `forceRender`). 350 | break; 351 | } 352 | _resetPendingRender(data); 353 | const parentData = data._renderParentData; 354 | invalidatePrepare = 355 | (parentData && parentData.childrenData && parentData.childrenData.prepareChildren.has(data._id)) || 356 | false; 357 | data = parentData; 358 | } 359 | } 360 | interruptedComponents.delete(rootId); 361 | } 362 | } 363 | 364 | function rerenderAllInterrupted() { 365 | if (rerenderInterruptedTimeout) { 366 | cancelSchedule(rerenderInterruptedTimeout); 367 | rerenderInterruptedTimeout = null; 368 | } 369 | interruptedComponents.forEach((item, id) => { 370 | const {root, rootData} = item; 371 | _rerenderInterrupted(id); 372 | // In addition to invalidating all respective render caches, 373 | // we also schedule another render pass of the root component. 374 | _forceRender(root, rootData, false); 375 | }); 376 | } 377 | 378 | function scheduleRerenderInterrupted() { 379 | if (!rerenderInterruptedTimeout) { 380 | rerenderInterruptedTimeout = scheduler(rerenderAllInterrupted, RENDER_BATCH_TIME); 381 | } 382 | } 383 | 384 | function scheduleDisappearHandlers() { 385 | if (!disappearHandlersTimeout) { 386 | disappearHandlersTimeout = scheduler(() => { 387 | disappearHandlersTimeout = null; 388 | for (let i = 0, l = unmountedComponents.length; i < l; ++i) { 389 | const component = unmountedComponents[i]; 390 | // If the component is still not mounted on the next tick, trigger onDisappear. 391 | if (!component.isMounted()) { 392 | component.onDisappear(); 393 | } 394 | } 395 | unmountedComponents.length = 0; 396 | }, 0); 397 | } 398 | } 399 | 400 | function _childFinishedRender(thisData: AnyInternalData, childData: AnyInternalData, child: AnyComponent, result: any) { 401 | const id = childData._id; 402 | getChildrenData(thisData).renderedChildren.set(id, { 403 | result, 404 | instance: child, 405 | children: childData.childrenData ? new Map(childData.childrenData.renderedChildren) : null, 406 | context: childData._context, 407 | usedContextAttributes: childData._usedContextAttributes.slice(0), 408 | descendantCount: 0 409 | }); 410 | addUsedContextAttributes(thisData._usedContextAttributes, childData._usedContextAttributes); 411 | } 412 | 413 | function _doRender( 414 | data: AnyInternalData, 415 | that: AnyComponent, 416 | arg: RenderArgs 417 | ): RenderResult { 418 | const prepare = data._prepare; 419 | if (prepare && prepare.isPending()) { 420 | DEBUG_REBACK && logger.debug(d`Cannot render ${that} because preparation is still pending`); 421 | throw new RenderPending(); 422 | } 423 | const methods = data.methods; 424 | const changedAttrs = data._changedAttributesSincePrepare; 425 | if (!(data.flags & FLAG_PREPARED) || methods.shouldPrepare.call(that, changedAttrs || EMPTY_SET)) { 426 | // Before running a fresh prepare, clear any used context attributes. 427 | // It is only safe to do this here (not before every render), since 428 | // context attributes might be used during prepare whose usage wouldn't be 429 | // restored if a previous prepare is reused. 430 | data._usedContextAttributes = []; 431 | if (changedAttrs) { 432 | changedAttrs.clear(); 433 | } 434 | // Whenever we prepare a component, also invalidate its render cache. 435 | // This is important since `doPrepare` might create new child components that are supposed to be used in `doRender`, 436 | // and we don't want to use an old render cache that referenced old child components. 437 | _invalidateRenderCache(data); 438 | do { 439 | const error = _doPrepare(data, that); 440 | const {childrenData} = data; 441 | if (childrenData) { 442 | const pc = (childrenData.prepareChildren = new Map(childrenData.renderedChildren)); 443 | iterChildren(pc, child => { 444 | addUsedContextAttributes(data._usedContextAttributes, child._reback._usedContextAttributes); 445 | }); 446 | } 447 | if (error) { 448 | throw error; 449 | } 450 | } while (data.flags & FLAG_NEEDS_PREPARE_AFTER_PREPARE); 451 | } else { 452 | const {childrenData} = data; 453 | if (childrenData) { 454 | childrenData.renderedChildren = new Map(childrenData.prepareChildren); 455 | } 456 | } 457 | const cached = data._renderCache.getEntry(arg); 458 | const prepareResult: PrepareResult = data._prepareResult as any; 459 | if ( 460 | cached === Cache.MISSING || 461 | didContextChange(data._context, cached.context, cached.usedContextAttributes) || 462 | methods.shouldRender.call(that, arg, prepareResult) 463 | ) { 464 | data.flags = setPhaseFlags(data.flags, Phase.RENDERING); 465 | const previousCount = renderState.renderComponentCount; 466 | const renderResult: any = methods.doRender.call(that, arg, prepareResult); 467 | if (renderResult !== RENDER_INTERRUPT && !(data.flags & FLAG_NEEDS_RENDER_AFTER_RENDER)) { 468 | const descendantCount = renderState.renderComponentCount - previousCount; 469 | const cacheEntry: CacheEntry = { 470 | result: renderResult, 471 | instance: that, 472 | children: data.childrenData ? new Map(data.childrenData.renderedChildren) : null, 473 | context: data._context, 474 | usedContextAttributes: data._usedContextAttributes.slice(0), 475 | descendantCount 476 | }; 477 | data._renderCache.setEntry(arg, cacheEntry); 478 | data._currentlyUsedCache = cacheEntry; 479 | } 480 | return renderResult; 481 | } else { 482 | DEBUG_REBACK && logger.debug(d`Reusing render cache for ${that}`); 483 | _useCache(data, that, cached); 484 | renderState.renderComponentCount += cached.descendantCount; 485 | return cached.result; 486 | } 487 | } 488 | 489 | /** 490 | * Sets up the component to use a cached render result. 491 | */ 492 | function _useCache(data: AnyInternalData, that: AnyComponent, cacheEntry: CacheEntry) { 493 | DEBUG_REBACK && logger.debug(d`Using cache for ${that}`); 494 | const result = cacheEntry.result; 495 | const children = cacheEntry.children; 496 | const used = cacheEntry.usedContextAttributes; 497 | data._renderResult = result; 498 | if (children) { 499 | const childrenData = getChildrenData(data); 500 | childrenData.renderedChildren = children; 501 | } else { 502 | if (data.childrenData) { 503 | data.childrenData.renderedChildren = new Map(); 504 | } 505 | } 506 | data._usedContextAttributes = used; 507 | const parentData = data._renderParentData; 508 | if (parentData) { 509 | addUsedContextAttributes(parentData._usedContextAttributes, used); 510 | } 511 | data.methods.onCachedRender.call(that, result); 512 | // If the cache entry is the one we've already been using, there's no need to recursively 513 | // update cached children. We know they are mounted correctly already. 514 | // (`_currentlyUsedCache` is reset when unmounting.) 515 | if (cacheEntry === data._currentlyUsedCache) { 516 | return; 517 | } 518 | data._currentlyUsedCache = cacheEntry; 519 | if (!children) { 520 | return; 521 | } 522 | DEBUG_REBACK && logger.debug(d`Using cache for children of ${that}`); 523 | for (const [_key, entry] of children) { 524 | const child = entry.instance; 525 | const childData = child._reback; 526 | if (childData._renderParent !== that) { 527 | DEBUG_REBACK && 528 | logger.debug(d`Remounting cached child ${child} from ${childData._renderParent} to ${that}`); 529 | const wasMounted = !!(childData.flags & FLAG_MOUNTED); 530 | const prevParent = childData._renderParent; 531 | const newParent = that; 532 | childData._renderParent = that; 533 | childData._renderParentData = data; 534 | childData.flags = setPhaseFlags(childData.flags | FLAG_MOUNTED, Phase.RESTORING); 535 | _remount(child, childData, wasMounted, prevParent, newParent); 536 | } 537 | childData.flags = setPhaseFlags(childData.flags, Phase.RENDERED); 538 | DEBUG_REBACK && logger.beginBlock(); 539 | _useCache(childData, child, entry); 540 | DEBUG_REBACK && logger.endBlock(); 541 | } 542 | } 543 | 544 | function _doPrepare(data: AnyInternalData, that: AnyComponent): Error | null { 545 | DEBUG_REBACK && logger.debug(d`Preparing ${that}`); 546 | data.flags = setPhaseFlags(data.flags & ~FLAG_PREPARED & ~FLAG_NEEDS_PREPARE_AFTER_PREPARE, Phase.PREPARING); 547 | let prepare = null; 548 | let [success, result] = tryCatch0(data.methods.doPrepare, that); 549 | if (result instanceof SyncPromise) { 550 | if (result.isFulfilled()) { 551 | result = result.getValueSync(); 552 | } else if (result.isRejected()) { 553 | success = false; 554 | result = result.getExceptionSync(); 555 | } 556 | } 557 | if (!success) { 558 | // Throw an error immediately. We don't want to keep on rendering if preparation fails synchronously. 559 | DEBUG_REBACK && logger.debug(d`Re-throwing error ${result} during preparation of ${that}`); 560 | data._prepare = null; 561 | return result; 562 | } 563 | const then = result ? result.then : null; 564 | if (typeof then === 'function') { 565 | prepare = data._prepare = then.call( 566 | result, 567 | value => { 568 | DEBUG_REBACK && logger.debug(d`Preparation of ${that} resolved`); 569 | // Only actually set the prepare result when this is still the "current" preparation. 570 | // E.g. if `.forceRender()` is called while this preparation is pending, 571 | // the prepare result should be ignored. 572 | // Note that `prepare` might still be undefined when this is running synchronously. 573 | // In that case do not ignore the result. 574 | if (!prepare || data._prepare === prepare) { 575 | data.flags |= FLAG_PREPARED; 576 | data._prepareResult = value; 577 | data._prepare = null; 578 | DEBUG_REBACK && logger.debug(d`Rendering ${that} because preparation resolved`); 579 | _forceRender(that, data, false); 580 | } else { 581 | DEBUG_REBACK && logger.debug(d`Ignoring preparation ${prepare} of ${that}`); 582 | } 583 | }, 584 | error => { 585 | DEBUG_REBACK && logger.debug(d`Preparation of ${that} threw an asynchronous error: ${error}`); 586 | _forceRender(that, data, false); 587 | if (!(error instanceof RenderPending)) { 588 | // Do not "persist" RenderPending errors. 589 | // Just re-render when a RenderPending is thrown asynchronously. 590 | // Do this after calling _forceRender, since that resets _renderError. 591 | getUncommonData(data).renderError = error; 592 | } 593 | } 594 | ); 595 | DEBUG_REBACK && logger.debug(d`Throw RenderPending in preparation of ${that}`); 596 | if (DEBUG_REBACK) { 597 | return new RenderPending({duringPrepareOf: that}); 598 | } 599 | return new RenderPending(); 600 | } else { 601 | data.flags |= FLAG_PREPARED; 602 | data._prepareResult = result; 603 | data._prepare = null; 604 | } 605 | return null; 606 | } 607 | 608 | function _performRender( 609 | data: AnyInternalData, 610 | arg: any, 611 | error: any, 612 | isRequired: boolean, 613 | isOptional: boolean, 614 | keepExistingChildren: boolean, 615 | that: AnyComponent 616 | ): [boolean, any, any] { 617 | const methods = data.methods; 618 | const childrenData = data.childrenData; 619 | const prevChildren = childrenData ? childrenData.renderedChildren : null; 620 | if (!keepExistingChildren) { 621 | if (childrenData) { 622 | childrenData.renderedChildren = new Map(); 623 | } 624 | } 625 | pushToRenderStack(that, data); 626 | data.flags = setPhaseFlags(data.flags, Phase.RENDERING); 627 | let isPending = false; 628 | let renderResult; 629 | let renderError; 630 | 631 | // If this current render pass has been interrupted by a previous component, or 632 | // if the previous render pass was not interrupted, or 633 | // if we're already past the point where the previous render pass was interrupted, 634 | // AND if this component asks for an interrupt, 635 | // then interrupt rendering, which renders the component in a pending state (for now) and schedules it 636 | // for re-rendering in the next render pass. 637 | const newComponents = renderState.renderComponentCount - renderState.lastRenderComponentCount; 638 | const mayInterrupt = renderState.isRenderInterrupted || !renderState.lastRenderWasInterrupted || newComponents > 0; 639 | let shouldInterrupt; 640 | if (mayInterrupt) { 641 | const time = now() - renderState.renderStartTime; 642 | shouldInterrupt = methods.shouldInterruptRender.call( 643 | that, 644 | renderState.renderInterruptGeneration, 645 | time, 646 | newComponents 647 | ); 648 | } else { 649 | shouldInterrupt = false; 650 | } 651 | if (shouldInterrupt && !renderState.isRenderInterrupted) { 652 | renderState.isRenderInterrupted = true; 653 | ++renderState.renderInterruptGeneration; 654 | } 655 | let success = true; 656 | let resultOrException; 657 | if (shouldInterrupt) { 658 | resultOrException = RENDER_INTERRUPT; 659 | } else { 660 | try { 661 | if (error) { 662 | resultOrException = methods.doRenderError.call(that, arg, error); 663 | } else { 664 | resultOrException = _doRender(data, that, arg); 665 | } 666 | } catch (err) { 667 | success = false; 668 | resultOrException = err; 669 | } 670 | } 671 | const isInterrupted = resultOrException === RENDER_INTERRUPT; 672 | if (isInterrupted) { 673 | if (DEBUG_REBACK) { 674 | getRenderAnalysisData(data).isInterrupted = true; 675 | } 676 | const root = getCurrentRenderRoot(); 677 | const rootData = getCurrentRenderRootData(); 678 | if (root && rootData) { 679 | const id = rootData._id; 680 | let item: InterruptedItem | void = interruptedComponents.get(id); 681 | if (!item) { 682 | item = {root, rootData, interrupted: []}; 683 | interruptedComponents.set(id, item); 684 | } 685 | const interrupted = item.interrupted; 686 | interrupted.push(data); 687 | scheduleRerenderInterrupted(); 688 | } 689 | } 690 | const parentData = data._renderParentData; 691 | if (success && !isInterrupted) { 692 | renderResult = resultOrException; 693 | const pending = data._pendingCompleteRender; 694 | if (pending) { 695 | DEBUG_REBACK && logger.debug(d`Resolving complete render for component ${that}`); 696 | data._pendingCompleteRender = null; 697 | pending.dangerouslyResolve(); 698 | } 699 | if (DEBUG_REBACK) { 700 | getRenderAnalysisData(data).success = true; 701 | } 702 | } else if (isInterrupted || resultOrException instanceof RenderPending) { 703 | // Throw away the previous result (that contains pending children), 704 | // but do not unmount those children. 705 | if (prevChildren) { 706 | const newChildrenData = getChildrenData(data); 707 | for (const [key, value] of prevChildren) { 708 | newChildrenData.renderedChildren.set(key, value); 709 | } 710 | } 711 | if (DEBUG_REBACK) { 712 | if (!isInterrupted) { 713 | getRenderAnalysisData(data).renderPendingThrown = resultOrException; 714 | } 715 | } 716 | if (isRequired || (parentData && !isOptional && parentData.methods.shouldWaitForChildren.call(that))) { 717 | DEBUG_REBACK && logger.debug(d`Required component ${that} is not ready yet`); 718 | if (DEBUG_REBACK) { 719 | getRenderAnalysisData(data).requiredButNotReady = true; 720 | } 721 | isPending = true; 722 | } else { 723 | DEBUG_REBACK && logger.debug(d`Render pending ${that} because RenderPending was thrown`); 724 | if (DEBUG_REBACK) { 725 | getRenderAnalysisData(data).renderPending = true; 726 | } 727 | renderResult = methods.doRenderPending.call(that, arg); 728 | } 729 | } else { 730 | renderError = resultOrException; 731 | if (DEBUG_REBACK) { 732 | getRenderAnalysisData(data).renderError = renderError; 733 | } 734 | } 735 | popFromRenderStack(); 736 | if (!keepExistingChildren) { 737 | PROFILE_REBACK && startTiming('_unmountPreviousChildren'); 738 | const newChildrenData = data.childrenData; 739 | if (prevChildren) { 740 | _unmountPreviousChildren( 741 | prevChildren, 742 | newChildrenData ? newChildrenData.renderedChildren : null, 743 | data, 744 | that 745 | ); 746 | } 747 | PROFILE_REBACK && stopTiming('_unmountPreviousChildren'); 748 | } 749 | if (parentData) { 750 | _childFinishedRender(parentData, data, that, renderResult); 751 | } 752 | return [isPending, renderResult, renderError]; 753 | } 754 | 755 | function _unmountPreviousChildren( 756 | prevChildren: Children, 757 | nextChildren: Children | null, 758 | data: AnyInternalData, 759 | that: AnyComponent 760 | ) { 761 | iterChildren(prevChildren, (child, id) => { 762 | if (!nextChildren || !nextChildren.has(id)) { 763 | DEBUG_REBACK && 764 | logger.debug(d`Unmounting ${child} from ${that} because it disappeared from its render tree`); 765 | _unmountFromParent(child, child._reback, that); 766 | } 767 | }); 768 | } 769 | 770 | function _unmountFromParent(that: AnyComponent, data: AnyInternalData, parent: AnyComponent | null) { 771 | /* 772 | Consider the situation where a descendant C is moved (where A is `that`): 773 | 774 | A 775 | +-- B 776 | +-- C 777 | ~> 778 | A 779 | +-- B 780 | +-- C 781 | 782 | The only place C can move during A's render pass is to somewhere else in A's hierarchy. 783 | At the end of A's render pass, if C is mounted into a different parent than A, 784 | it shall not actually be unmounted. 785 | */ 786 | const isMountedIntoAnotherParent = data._renderParent !== parent; 787 | if (!isMountedIntoAnotherParent) { 788 | DEBUG_REBACK && logger.debug(d`Unmounting children of ${that}`); 789 | DEBUG_REBACK && logger.beginBlock(); 790 | const childrenData = data.childrenData; 791 | if (childrenData) { 792 | iterChildren(childrenData.renderedChildren, child => { 793 | _unmountFromParent(child, child._reback, that); 794 | }); 795 | } 796 | DEBUG_REBACK && logger.debug(d`Unmounting ${that}`); 797 | data.flags = setPhaseFlags(data.flags, Phase.UNMOUNTING); 798 | // We need to invalidate the prepare cache since, otherwise, a component that is unmounted and later mounted again 799 | // wouldn't run `doPrepare` again, leaving its prepare-phase children still unmounted. 800 | // See https://stash.wolfram.com/projects/CLOUD/repos/cloudplatform/pull-requests/10218/overview?commentId=523159 and related discussion. 801 | _invalidatePrepareCache(data); 802 | // We need to invalidate the whole render cache as soon as the component unmounts. 803 | // While the component is unmounted, some cache-invalidating events (such as changed attributes) 804 | // don't propagate up the render tree (since unmounted components have no parent), 805 | // so when they remount any cached render result might not be valid anymore. 806 | // This resolves CLOUD-10762. 807 | _invalidateRenderCache(data); 808 | DEBUG_REBACK && logger.debug(d`Calling onUnmount of ${that}`); 809 | if (!(data.flags & FLAG_ERROR_DURING_INITIALIZE)) { 810 | data.methods.onUnmount.call(that); 811 | } 812 | data.flags &= ~FLAG_MOUNTED; 813 | data._renderParent = null; 814 | data._renderParentData = null; 815 | // Note that we don't reset the `_renderResult` here. 816 | // Even if a component is unmounted, it is useful to remember the last render result, 817 | // so that when it is remounted and pending, we have at least *something* to show temporarily. 818 | if (!(data.flags & FLAG_ERROR_DURING_INITIALIZE)) { 819 | unmountedComponents.push(that); 820 | scheduleDisappearHandlers(); 821 | } 822 | DEBUG_REBACK && logger.endBlock(); 823 | } 824 | } 825 | 826 | function _resetPendingRender(data: AnyInternalData) { 827 | if (!data._pendingRender) { 828 | data._pendingRender = new SyncPromise(); 829 | } 830 | if (!data._pendingCompleteRender) { 831 | data._pendingCompleteRender = new SyncPromise(); 832 | } 833 | } 834 | 835 | function _requestRender(that: AnyComponent, data: AnyInternalData) { 836 | const parent = data._renderParent; 837 | const parentData = data._renderParentData; 838 | if (parent && parentData) { 839 | DEBUG_REBACK && logger.debug(d`Triggering needs-render on ${that}`); 840 | const childrenData = parentData.childrenData; 841 | if (childrenData && childrenData.prepareChildren.has(data._id)) { 842 | _forcePrepare(parent, parentData); 843 | } else { 844 | _forceRender(parent, parentData, false); 845 | } 846 | } else { 847 | const rootData = getRootData(data); 848 | DEBUG_REBACK && logger.debug(d`Existing timeout: ${rootData.needsRenderTimeout}`); 849 | if (!rootData.needsRenderTimeout) { 850 | DEBUG_REBACK && logger.debug(d`Scheduling needs-render for ${that}`); 851 | rootData.needsRenderTimeout = scheduler(() => { 852 | rootData.needsRenderTimeout = null; 853 | DEBUG_REBACK && logger.debug(d`Triggering batched needs-render on ${that}`); 854 | const onRequestRender = getRootData(data).onRequestRender; 855 | if (onRequestRender) { 856 | onRequestRender(); 857 | } 858 | }, RENDER_BATCH_TIME); 859 | } 860 | } 861 | } 862 | 863 | function _enterRender(that: AnyComponent, data: AnyInternalData, context?: Context | null | void) { 864 | PROFILE_REBACK && startTiming('_enterRender'); 865 | data.flags = setPhaseFlags(data.flags & ~FLAG_KEPT_MOUNTED, Phase.MOUNTING); 866 | const pending = data._pendingRender; 867 | if (pending) { 868 | data._pendingRender = null; 869 | pending.dangerouslyResolve(); 870 | } 871 | const prevParent = data._renderParent; 872 | const newParent = getCurrentRenderParent(); 873 | const wasMounted = !!(data.flags & FLAG_MOUNTED); 874 | data.flags |= FLAG_MOUNTED; 875 | data._renderParent = newParent; 876 | data._renderParentData = getCurrentRenderParentData(); 877 | if (DEBUG_REBACK) { 878 | const renderPassStartTime = renderState.renderStartTime; 879 | if (newParent !== prevParent) { 880 | // We use the render pass start time to identify a render pass here. That should be fine since the 881 | // resolution of the timestamps is very granular. (But even if there's a collision -- two separate 882 | // render passes with the same start time -- it wouldn't be the end of the world, you might just get 883 | // unnecessary warnings when DEBUG_REBACK is on.) 884 | if (renderPassStartTime === data.debugRenderParentRenderPassStart) { 885 | logger.warn(d`Remounting component ${that} from ${prevParent} to ${newParent} \ 886 | during the same render pass. This is probably a bug in your code that will cause problems. \ 887 | A component should only be mounted into a single parent.`); 888 | } 889 | if (newParent === that) { 890 | logger.warn(d`Mounting component ${that} into itself. This will cause problems.`); 891 | } 892 | } 893 | data.debugRenderParentRenderPassStart = renderPassStartTime; 894 | } 895 | data.flags &= ~FLAG_NEEDS_RENDER_AFTER_RENDER; 896 | // This component is only added to `renderedChildren` of the parent when it is finished rendering. 897 | // If the parent changed or if this is the root component and wasn't mounted before, 898 | // then it is (re-) mounted. 899 | if (newParent !== prevParent || (!newParent && !wasMounted)) { 900 | DEBUG_REBACK && logger.debug(d`Remounting ${that} from ${prevParent} to ${newParent}`); 901 | _remount(that, data, wasMounted, prevParent, newParent, context); 902 | } else { 903 | _updateContext(that, data, newParent, context); 904 | } 905 | PROFILE_REBACK && stopTiming('_enterRender'); 906 | } 907 | 908 | function _remount( 909 | that: AnyComponent, 910 | data: AnyInternalData, 911 | wasMounted: boolean, 912 | prevParent: AnyComponent | null, 913 | newParent: AnyComponent | null, 914 | context?: Context | null | void 915 | ) { 916 | if (wasMounted) { 917 | _unmountFromParent(that, data, prevParent); 918 | } 919 | // Update the context before running onAppear and onMount. 920 | _updateContext(that, data, newParent, context); 921 | if (!(data.flags & FLAG_ERROR_DURING_INITIALIZE)) { 922 | const methods = data.methods; 923 | // If this component didn't have a parent before, it's mounted for the first time. 924 | if (!wasMounted) { 925 | methods.onAppear.call(that); 926 | } 927 | methods.onMount.call(that); 928 | } 929 | } 930 | 931 | function _updateContext( 932 | that: AnyComponent, 933 | data: AnyInternalData, 934 | parent?: AnyComponent | null, 935 | givenContext?: Context | null | void 936 | ) { 937 | DEBUG_REBACK && logger.debug(d`Determining context for ${that} with parent ${parent}`); 938 | PROFILE_REBACK && startTiming('_updateContext'); 939 | const prevContext = data._context; 940 | const context = givenContext || (parent ? parent.getModifiedContext() : emptyContext); 941 | PROFILE_REBACK && stopTiming('_updateContext'); 942 | if (context !== prevContext) { 943 | if (DEBUG_REBACK && context.attributes && prevContext && prevContext.attributes) { 944 | const cmp = compareMaps(prevContext.attributes, context.attributes); 945 | logger.debug(`Context comparison: ${JSON.stringify(cmp)}`); 946 | } 947 | data._context = context; 948 | data._boundContext = null; 949 | if (didContextChange(context, prevContext, data._usedContextAttributes)) { 950 | _invalidatePrepareCache(data); 951 | _invalidateRenderCache(data); 952 | } 953 | DEBUG_REBACK && logger.debug(d`Context for ${that} changed`); 954 | if (!(data.flags & FLAG_ERROR_DURING_INITIALIZE)) { 955 | data.methods.onReceiveContext.call(that, prevContext); 956 | } 957 | } 958 | } 959 | 960 | /** 961 | * Returns a context object "bound" to the component instance, 962 | * keeping track of what context attributes are accessed per instance. 963 | * @param data Internal component data. 964 | */ 965 | function _getBoundContext( 966 | data: RebackInternalData 967 | ): ContextType | null { 968 | const context = data._context; 969 | if (context) { 970 | let boundContext = data._boundContext; 971 | if (boundContext) { 972 | return boundContext; 973 | } 974 | boundContext = context.changeComponent(data); 975 | data._boundContext = boundContext; 976 | return boundContext; 977 | } else { 978 | return null; 979 | } 980 | } 981 | 982 | function _walkTree(that: AnyComponent, data: AnyInternalData, callback: (descendant: AnyComponent) => void) { 983 | callback(that); 984 | const childrenData = data.childrenData; 985 | if (childrenData) { 986 | iterChildren(childrenData.renderedChildren, child => _walkTree(child, child._reback, callback)); 987 | } 988 | } 989 | 990 | function _invalidatePrepareCache(data: AnyInternalData) { 991 | // Set a new promise for now. This is checked for in `_doPrepare`, and any result 992 | // from a pending preparation is subsequently ignored. 993 | DEBUG_REBACK && logger.debug(d`Invalidating prepare cache of ${data._id}`); 994 | data._prepare = null; 995 | data.flags &= ~FLAG_PREPARED; 996 | data._prepareResult = null; 997 | const childrenData = data.childrenData; 998 | if (childrenData) { 999 | childrenData.prepareChildren = new Map(); 1000 | } 1001 | } 1002 | 1003 | function _invalidateRenderCache(data: AnyInternalData) { 1004 | data._renderCache.empty(); 1005 | data._currentlyUsedCache = null; 1006 | 1007 | // E.g. when state changes, forget about a previous render error 1008 | // -- unless the error happened during initialize. 1009 | // This allows cells to "catch" dynamic update errors, change their state (forcedRenderMode), 1010 | // and render again. 1011 | if (!(data.flags & FLAG_ERROR_DURING_INITIALIZE)) { 1012 | if (data.uncommonData) { 1013 | data.uncommonData.renderError = null; 1014 | } 1015 | } 1016 | } 1017 | 1018 | function _forceRender(that: AnyComponent, data: AnyInternalData, notDuringRender: boolean) { 1019 | // Invalidate the render cache immediately, regardless of this component's phase. 1020 | // This is important because the component might have been unmounted in the meanwhile, but the 1021 | // next time it renders it still shouldn't reuse a previous render cache (cf. CLOUD-7731). 1022 | _invalidateRenderCache(data); 1023 | const phase = data.flags & MASK_PHASE; 1024 | if (phase === Phase.RENDERING && !(data.flags & FLAG_AVOID_RENDER_AFTER_RENDER) && !notDuringRender) { 1025 | DEBUG_REBACK && logger.info(d`Schedule render after rendering ${that}`); 1026 | data.flags |= FLAG_NEEDS_RENDER_AFTER_RENDER; 1027 | return; 1028 | } 1029 | // If this component is still about to render (i.e. its phase is before RENDERED) or it is already unmounting, 1030 | // then it's safe to ignore the render request. 1031 | if (phase !== Phase.RENDERED && phase !== Phase.RESTORING) { 1032 | DEBUG_REBACK && 1033 | logger.debug(d`Ignoring render request for ${that} because it is in phase ${PHASE_NAMES[phase]}`); 1034 | return; 1035 | } 1036 | DEBUG_REBACK && logger.debug(d`Forcing render of ${that}`); 1037 | _resetPendingRender(data); 1038 | _requestRender(that, data); 1039 | } 1040 | 1041 | function _forcePrepare(that: AnyComponent, data: AnyInternalData) { 1042 | const phase = data.flags & MASK_PHASE; 1043 | if (phase === Phase.PREPARING) { 1044 | DEBUG_REBACK && logger.info(d`Schedule prepare after preparing ${that}`); 1045 | data.flags |= FLAG_NEEDS_PREPARE_AFTER_PREPARE; 1046 | return; 1047 | } 1048 | // Ignore prepare requests when a render is underway, similarly to `forceRender`. 1049 | if (phase !== Phase.RENDERED && phase !== Phase.RESTORING && phase !== Phase.RENDERING) { 1050 | return; 1051 | } 1052 | _invalidatePrepareCache(data); 1053 | _forceRender(that, data, false); 1054 | } 1055 | 1056 | function _setState( 1057 | that: AnyComponent, 1058 | data: AnyInternalData, 1059 | state: State, 1060 | onChange: OnChange, 1061 | values: Pick 1062 | ) { 1063 | let hasChanged = false; 1064 | // Accumulate all state waiters that need to be resolved and resolve them at the end, 1065 | // so that any other attributes updated in the same .setState call can be assumed to updated as well 1066 | // by the time a state change fires. 1067 | let allWaiters: Array<() => void> | null = null; 1068 | for (const name in values) { 1069 | if (values.hasOwnProperty(name)) { 1070 | const value = values[name]; 1071 | const oldValue = state[name]; 1072 | if (oldValue !== value && !(Number.isNaN(oldValue) && Number.isNaN(value))) { 1073 | state[name] = value as any; 1074 | const changeListener = onChange && onChange[name]; 1075 | if (changeListener) { 1076 | changeListener.call(that, value, oldValue); 1077 | } 1078 | const phase = data.flags & MASK_PHASE; 1079 | if (phase > Phase.MOUNTING) { 1080 | let changedAttrs = data._changedAttributesSincePrepare; 1081 | if (!changedAttrs) { 1082 | changedAttrs = data._changedAttributesSincePrepare = new Set(); 1083 | } 1084 | changedAttrs.add(name); 1085 | } 1086 | const waiters = data.uncommonData ? data.uncommonData.stateWaiters[name] : null; 1087 | if (waiters) { 1088 | for (let i = 0, l = waiters.length; i < l; ++i) { 1089 | const waiter = waiters[i]; 1090 | if (waiter && value === waiter.value) { 1091 | if (!allWaiters) { 1092 | allWaiters = []; 1093 | } 1094 | allWaiters.push(waiter.resolve); 1095 | // TODO: We should clean up the waiters list at some point. 1096 | waiters[i] = null; 1097 | } 1098 | } 1099 | } 1100 | hasChanged = true; 1101 | } 1102 | } 1103 | } 1104 | if (hasChanged) { 1105 | if (allWaiters) { 1106 | for (let i = 0, l = allWaiters.length; i < l; ++i) { 1107 | allWaiters[i](); 1108 | } 1109 | } 1110 | if (DEBUG_REBACK) { 1111 | logger.info(d`Rerendering ${that} due to changed attributes`); 1112 | } 1113 | // We really have to force a render here (i.e. also invalidate render caches), not only request it. 1114 | // Otherwise only the next render pass would enter doRender, but subsequent passes after that might 1115 | // still have a cache (e.g. the notebook would show the cell separator in an old location after scrolling). 1116 | _forceRender(that, data, false); 1117 | } 1118 | } 1119 | 1120 | function _render( 1121 | that: AnyComponent, 1122 | data: AnyInternalData, 1123 | arg: any, 1124 | context: Context | null | void, 1125 | isRequired: boolean, 1126 | isOptional: boolean, 1127 | recursion: number 1128 | ) { 1129 | DEBUG_REBACK && logger.beginBlock(); 1130 | let result; 1131 | const previousRenderError = data.uncommonData ? data.uncommonData.renderError : null; 1132 | if (previousRenderError) { 1133 | result = _performRender(data, arg, previousRenderError, isRequired, isOptional, false, that); 1134 | } else { 1135 | // `doRender` takes care of preparing the component (calling `doRenderPending` while the preparation 1136 | // is pending). 1137 | result = _performRender(data, arg, null, isRequired, isOptional, false, that); 1138 | } 1139 | let [isPending, renderResult, renderError] = result; 1140 | if (!isPending && renderError) { 1141 | // When rendering in the error state after a first render iteration, 1142 | // keep the children from the first iteration. Otherwise, the children from the 1143 | // regular render pass would get unmounted, and a potential state change of them 1144 | // (which might resolve the render error) would not trigger a rerender of this 1145 | // (parent) component. 1146 | const errorResult = _performRender(data, arg, renderError, isRequired, isOptional, true, that); 1147 | const repeatedError = errorResult[2]; 1148 | if (repeatedError) { 1149 | // If there's an error during doRenderError, just throw it. 1150 | DEBUG_REBACK && 1151 | logger.warn(d`Repeated synchronous rendering error: ${repeatedError} after: ${renderError}`); 1152 | if (DEBUG_REBACK) { 1153 | getRenderAnalysisData(data).repeatedError = repeatedError; 1154 | } 1155 | throw repeatedError; 1156 | } else { 1157 | isPending = errorResult[0]; 1158 | renderResult = errorResult[1]; 1159 | renderError = null; 1160 | if (DEBUG_REBACK) { 1161 | getRenderAnalysisData(data).renderedAsError = true; 1162 | } 1163 | } 1164 | } 1165 | data._renderResult = renderResult; 1166 | // Need to make sure that the phase is set to RENDERED in the end. 1167 | // Otherwise required `forceRender` and `forcePrepare` requests are possibly ignored 1168 | // (e.g. when `render` throws a `RenderPending` first but eventually its prepare promise resolves, 1169 | // the phase should not be PREPARING anymore). 1170 | data.flags = setPhaseFlags(data.flags, Phase.RENDERED); 1171 | if (!renderState.isRenderInterrupted) { 1172 | ++renderState.renderComponentCount; 1173 | } 1174 | DEBUG_REBACK && logger.endBlock(); 1175 | if (isPending) { 1176 | DEBUG_REBACK && logger.debug(d`Caught RenderPending while rendering ${that}, re-throwing`); 1177 | if (DEBUG_REBACK) { 1178 | throw new RenderPending({rethrownBy: that}); 1179 | } 1180 | throw new RenderPending(); 1181 | } 1182 | if (data.flags & FLAG_NEEDS_RENDER_AFTER_RENDER) { 1183 | if (recursion < 10) { 1184 | _enterRender(that, data, context); 1185 | return _render(that, data, arg, context, isRequired, isOptional, recursion + 1); 1186 | } else { 1187 | if (DEBUG_REBACK) { 1188 | getRenderAnalysisData(data).recursionLimitReached = true; 1189 | } 1190 | _forceRender(that, data, false); 1191 | } 1192 | } 1193 | return renderResult; 1194 | } 1195 | 1196 | function tryCatch0(t, thisArg): [boolean, any] { 1197 | try { 1198 | return [true, t.call(thisArg)]; 1199 | } catch (e) { 1200 | return [false, e]; 1201 | } 1202 | } 1203 | 1204 | class RebackVirtualMethods { 1205 | onAppear: () => void; 1206 | onMount: () => void; 1207 | onUnmount: () => void; 1208 | onDisappear: () => void; 1209 | getPrepareContextModifications: () => {[name: string]: any} | null; 1210 | getContextModifications: (prepareResult: PrepareResult) => {[name: string]: any} | null; 1211 | onReceiveContext: (prevContext?: ContextType) => void; 1212 | doPrepare: () => any; 1213 | doRender: (arg: RenderArgs, pr: PrepareResult) => any; 1214 | doRenderPending: (arg: RenderArgs) => any; 1215 | doRenderError: (arg: RenderArgs, error: any) => any; 1216 | onCachedRender: (renderResult: RenderResult) => void; 1217 | shouldPrepare: (changedAttrs: ReadonlySet) => boolean; 1218 | shouldRender: (arg: RenderArgs, pr: PrepareResult) => boolean; 1219 | shouldWaitForChildren: () => boolean; 1220 | shouldInterruptRender: (generation: number, time: number, components: number) => boolean; 1221 | 1222 | constructor(component: AnyComponent | RebackVirtualMethods) { 1223 | this.onAppear = component.onAppear; 1224 | this.onMount = component.onMount; 1225 | this.onUnmount = component.onUnmount; 1226 | this.onDisappear = component.onDisappear; 1227 | this.getPrepareContextModifications = component.getPrepareContextModifications; 1228 | this.getContextModifications = component.getContextModifications; 1229 | this.onReceiveContext = component.onReceiveContext; 1230 | this.doPrepare = component.doPrepare; 1231 | this.doRender = component.doRender; 1232 | this.doRenderPending = component.doRenderPending; 1233 | this.doRenderError = component.doRenderError; 1234 | this.onCachedRender = component.onCachedRender; 1235 | this.shouldPrepare = component.shouldPrepare; 1236 | this.shouldRender = component.shouldRender; 1237 | this.shouldWaitForChildren = component.shouldWaitForChildren; 1238 | this.shouldInterruptRender = component.shouldInterruptRender; 1239 | } 1240 | 1241 | clone() { 1242 | return new RebackVirtualMethods(this); 1243 | } 1244 | } 1245 | 1246 | interface CacheInterface { 1247 | setEntry(key: RenderArgs, value: CacheEntry): void; 1248 | getEntry(key: RenderArgs): CacheEntry; 1249 | empty(): void; 1250 | getSize(): number; 1251 | } 1252 | 1253 | /** 1254 | * Data that's relevant for root components. 1255 | */ 1256 | type RootData = { 1257 | onRequestRender: (() => void) | null; 1258 | interruptGeneration: number; 1259 | componentCount: number; 1260 | needsRenderTimeout: ScheduleID | null; 1261 | }; 1262 | 1263 | /** 1264 | * Data that's relatively uncommon. 1265 | * Note that this includes another indirection to RootData. 1266 | */ 1267 | type UncommonData = { 1268 | rootData: null | RootData; 1269 | stateWaiters: {[name: string]: Array<{value: any; resolve: () => void} | null>}; 1270 | renderError: any; 1271 | }; 1272 | 1273 | function getUncommonData(data: AnyInternalData): UncommonData { 1274 | let result = data.uncommonData; 1275 | if (!result) { 1276 | result = data.uncommonData = { 1277 | rootData: null, 1278 | stateWaiters: {}, 1279 | renderError: null 1280 | }; 1281 | } 1282 | return result; 1283 | } 1284 | 1285 | function getRootData(data: AnyInternalData): RootData { 1286 | const uncommonData = getUncommonData(data); 1287 | let rootData = uncommonData.rootData; 1288 | if (!rootData) { 1289 | rootData = uncommonData.rootData = { 1290 | onRequestRender: null, 1291 | interruptGeneration: 0, 1292 | componentCount: 0, 1293 | needsRenderTimeout: null 1294 | }; 1295 | } 1296 | return rootData; 1297 | } 1298 | 1299 | /** 1300 | * Data that's relevant for components that have children. 1301 | * This is kept in a separate (nullable) property as an optimization, 1302 | * so that components without any children don't have to store all these properties. 1303 | */ 1304 | type ChildrenData = { 1305 | prepareChildren: Children; 1306 | renderedChildren: Children; 1307 | modifiedPrepareContextCache: {base?: any; modifications?: any; result?: any}; 1308 | modifiedContextCache: {base?: any; modifications?: any; result?: any}; 1309 | }; 1310 | 1311 | function getChildrenData(data: AnyInternalData): ChildrenData { 1312 | let result = data.childrenData; 1313 | if (!result) { 1314 | result = data.childrenData = { 1315 | prepareChildren: new Map(), 1316 | renderedChildren: new Map(), 1317 | modifiedPrepareContextCache: {}, 1318 | modifiedContextCache: {} 1319 | }; 1320 | } 1321 | return result; 1322 | } 1323 | 1324 | type RenderAnalysisData = { 1325 | isInterrupted?: boolean; 1326 | recursionLimitReached?: boolean; 1327 | renderedAsError?: boolean; 1328 | renderError?: any; 1329 | renderPending?: boolean; 1330 | renderPendingThrown?: any; 1331 | repeatedError?: any; 1332 | requiredButNotReady?: boolean; 1333 | success?: boolean; 1334 | }; 1335 | 1336 | function getRenderAnalysisData(data: AnyInternalData): RenderAnalysisData { 1337 | let result = data.debugRenderAnalysisData; 1338 | if (!result) { 1339 | result = data.debugRenderAnalysisData = {}; 1340 | } 1341 | return result; 1342 | } 1343 | 1344 | /** 1345 | * Internal data associated with each Component. We keep this in a separate datastructure (as opposed to using properties 1346 | * directly on the Component) mainly for two reasons: 1347 | * 1. It's better encapsulation. There's less risk of subclasses using properties with the same name. 1348 | * 2. It keeps many internal methods monomorphic, since we don't need to pass arbitrary Component instances to them (and access 1349 | * their properties), but we only have to access properties on this internal datastructure. 1350 | */ 1351 | class RebackInternalData { 1352 | /** 1353 | * A bitfield containing both the component phase (in the 3 least significant bits) 1354 | * and various boolean flags. 1355 | */ 1356 | flags: number; 1357 | 1358 | _id: ID; 1359 | 1360 | _changedAttributesSincePrepare: Set | null; 1361 | _context: null | ContextType; 1362 | _boundContext: null | ContextType; 1363 | _currentlyUsedCache: CacheEntry | null; 1364 | 1365 | /** 1366 | * Potentially asynchronous preparation of the component. 1367 | * A value of `null` is equivalent to a resolved promise. 1368 | * The preparation result is stored in `_prepareResult` once it is done. 1369 | */ 1370 | _prepare: SyncPromise | null; 1371 | 1372 | _prepareResult: PrepareResult | null; 1373 | _renderCache: CacheInterface; 1374 | _renderParent: AnyComponent | null; 1375 | _renderParentData: AnyInternalData | null; 1376 | _renderResult: null | RenderResult; 1377 | 1378 | /** 1379 | * Context attributes used by this component or its descendants. 1380 | * The straight-forward type for this would be a Set of strings, 1381 | * but a (sorted) array of numeric keys 1382 | * (relying on the mapping contextKeysByName in Context.js) 1383 | * seems to be a little more efficient, in theory and practice. 1384 | */ 1385 | _usedContextAttributes: Array; 1386 | 1387 | childrenData: null | ChildrenData; 1388 | 1389 | _pendingRender: SyncPromise | null; 1390 | _pendingCompleteRender: SyncPromise | null; 1391 | 1392 | /** 1393 | * Direct pointers to lifecycle hooks and other "virtual" methods. 1394 | * This way, they can be called directly from a RebackInternalData instance, as opposed to having to dispatch from `this`. 1395 | * NOTE 1: Since we only populate this "cache" once in the Component structure, subclasses must define these methods 1396 | * on their prototype. Setting e.g. `this.onMount` on an instance won't work. 1397 | * NOTE 2: We're not storing bound method pointers, but only references to functions on the prototype. 1398 | * So any call must make sure to pass the right `this` pointer, usually via `.call`. 1399 | */ 1400 | methods: RebackVirtualMethods; 1401 | 1402 | uncommonData: UncommonData | null; 1403 | 1404 | /** 1405 | * Start of the parent component's render pass. 1406 | * This is only defined if `DEBUG_REBACK` is true, to avoid any performance overhead otherwise. 1407 | */ 1408 | debugRenderParentRenderPassStart?: number | null; 1409 | 1410 | /** 1411 | * Property for debugging what happened during the last render pass of this component and what it might be 1412 | * "waiting for". 1413 | * This is only defined if `DEBUG_REBACK` is true, to avoid any performance overhead otherwise. 1414 | */ 1415 | debugRenderAnalysisData?: RenderAnalysisData | null; 1416 | 1417 | constructor( 1418 | id: number, 1419 | renderCache: CacheInterface, 1420 | methods: RebackVirtualMethods 1421 | ) { 1422 | this.flags = Phase.CREATING; 1423 | this._id = id; 1424 | this._renderParent = null; 1425 | this._renderParentData = null; 1426 | this._context = null; 1427 | this._boundContext = null; 1428 | this._prepare = null; 1429 | this._prepareResult = null; 1430 | this._changedAttributesSincePrepare = null; 1431 | this._renderCache = renderCache; 1432 | this._currentlyUsedCache = null; 1433 | this._renderResult = null; 1434 | this._usedContextAttributes = []; 1435 | this.childrenData = null; 1436 | this._pendingRender = null; 1437 | this._pendingCompleteRender = null; 1438 | this.methods = methods; 1439 | this.uncommonData = null; 1440 | if (DEBUG_REBACK) { 1441 | this.debugRenderParentRenderPassStart = null; 1442 | this.debugRenderAnalysisData = null; 1443 | } 1444 | } 1445 | } 1446 | 1447 | export type AnyInternalData = RebackInternalData; 1448 | 1449 | type OnChange = {[_name in keyof State]: (value: any, oldValue: any) => void} | void; 1450 | 1451 | export default class Component< 1452 | PrepareResult = void, 1453 | RenderArgs = void, 1454 | RenderResult = unknown, 1455 | State extends {[name: string]: any} = {}, 1456 | ContextType extends Context = Context 1457 | > { 1458 | /** 1459 | * The current "state" of the component, similar to React's state. 1460 | * Can be read directly via accessing `.state`, but should only be written to using `.setState` 1461 | * (or `.setStateDuringRender` when necessary). 1462 | * Changing the state causes the component to re-prepare and re-render (unless `shouldPrepare` or `shouldRender` 1463 | * say otherwise, respectively). 1464 | */ 1465 | state: State; 1466 | 1467 | cid: string; 1468 | 1469 | onChange: OnChange; 1470 | onEvent: {[name: string]: (event: any, target: AnyComponent) => void} | void; 1471 | 1472 | _reback: RebackInternalData; 1473 | 1474 | /** 1475 | * Cache of "virtual" methods per component class. 1476 | */ 1477 | static methodsCache: RebackVirtualMethods; 1478 | 1479 | /** 1480 | * Set a scheduler function (default: `setTimeout`). 1481 | * This should only be used for testing purposes. 1482 | */ 1483 | static setScheduler(schedulerFunc: SchedulerFunc, cancelScheduleFunc: CancelScheduleFunc) { 1484 | scheduler = schedulerFunc; 1485 | cancelSchedule = cancelScheduleFunc; 1486 | } 1487 | 1488 | static render( 1489 | component: Component, 1490 | arg?: Args, 1491 | options?: RenderOptions 1492 | ) { 1493 | return component.renderRoot(arg, options); 1494 | } 1495 | 1496 | static isRenderInterrupted(): boolean { 1497 | return renderState.isRenderInterrupted; 1498 | } 1499 | 1500 | constructor(...args: any[]) { 1501 | const id = ++idCounter; 1502 | this.cid = `c${id}`; 1503 | this.state = (this.defaults() || {}) as any; 1504 | let renderCache; 1505 | const maxSize = this.getMaxRenderCacheSize(); 1506 | if (this.canHashRenderArg()) { 1507 | if (maxSize === 1) { 1508 | renderCache = new SingleEntryCache({keyHash: this.getRenderArgHash.bind(this)}); 1509 | } else { 1510 | renderCache = new HashCache({ 1511 | maxSize, 1512 | keyHash: this.getRenderArgHash.bind(this) 1513 | }); 1514 | } 1515 | } else { 1516 | renderCache = new Cache({ 1517 | maxSize, 1518 | keyComparator: sameShallow 1519 | }); 1520 | } 1521 | // Store the methods object on the constructor (the component class) and reuse it from there if available. 1522 | // We want to avoid having to construct this object per instance of a component. 1523 | // Note that we have to be careful to check for the constructor's *own* property, otherwise subclasses 1524 | // would reuse the cache of their base classes. 1525 | const constructor: any = this.constructor; 1526 | let methods = constructor.hasOwnProperty('methodsCache') ? constructor.methodsCache : null; 1527 | if (!methods) { 1528 | methods = constructor.methodsCache = new RebackVirtualMethods(this); 1529 | } 1530 | const data = (this._reback = new RebackInternalData(id, renderCache, methods)); 1531 | _resetPendingRender(data); 1532 | 1533 | try { 1534 | this.initialize(...args); 1535 | this.postInitialize(); 1536 | } catch (error) { 1537 | getUncommonData(data).renderError = error; 1538 | // Remember that an error happened during initialization; we don't call any of the lifecycle hooks 1539 | // (onAppear, onMount, onReceiveContext, onUnmount, onDisappear) in that case, since they should be able 1540 | // to expect a fully initialized component. 1541 | data.flags |= FLAG_ERROR_DURING_INITIALIZE; 1542 | } 1543 | } 1544 | 1545 | // -------------------------------------------------- 1546 | // Public methods 1547 | // -------------------------------------------------- 1548 | 1549 | initialize(...args: any) {} 1550 | 1551 | postInitialize() {} 1552 | 1553 | unrenderRoot() { 1554 | const data = this._reback; 1555 | if (data._renderParent) { 1556 | DEBUG_REBACK && logger.warn('Trying to unrender a non-top-level component'); 1557 | } else { 1558 | _unmountFromParent(this, data, null); 1559 | } 1560 | } 1561 | 1562 | keepMounted() { 1563 | const data = this._reback; 1564 | const prevParent = data._renderParent; 1565 | const newParent = getCurrentRenderParent(); 1566 | const newParentData = getCurrentRenderParentData(); 1567 | const wasMounted = data.flags & FLAG_MOUNTED; 1568 | if (wasMounted && prevParent === newParent && !data._pendingRender) { 1569 | data.flags |= FLAG_KEPT_MOUNTED; 1570 | if (newParent && newParentData) { 1571 | _childFinishedRender(newParentData, data, this, data._renderResult); 1572 | } else { 1573 | DEBUG_REBACK && logger.warn(d`Keeping component ${this} mounted without any parent`); 1574 | } 1575 | return true; 1576 | } else { 1577 | data.flags &= ~FLAG_KEPT_MOUNTED; 1578 | return false; 1579 | } 1580 | } 1581 | 1582 | isKeptMounted(): boolean { 1583 | return !!(this._reback.flags & FLAG_KEPT_MOUNTED); 1584 | } 1585 | 1586 | getContext(): ContextType { 1587 | const data = this._reback; 1588 | // TODO: We should probably throw an exception if there is no context yet. 1589 | // A lot of code currently relies on getContext() returning a non-null context, 1590 | // since it's called after or during rendering anyway. 1591 | // @ts-ignore 1592 | return _getBoundContext(data); 1593 | } 1594 | 1595 | getModifiedContext(): ContextType { 1596 | // Cache the previously generated context. 1597 | // If this parent's context and its intended modifications stay the same, 1598 | // keep using the same context. 1599 | // (We don't want to notify components of context changes unnecessarily.) 1600 | const data = this._reback; 1601 | const methods = data.methods; 1602 | const base = data._context || emptyContext; 1603 | const prepareModifications = methods.getPrepareContextModifications.call(this); 1604 | const childrenData = getChildrenData(data); 1605 | const prepareContext = applyModificationsCached( 1606 | base, 1607 | prepareModifications, 1608 | childrenData.modifiedPrepareContextCache, 1609 | { 1610 | sameValue: base.sameValue 1611 | } 1612 | ); 1613 | if (!(data.flags & FLAG_PREPARED)) { 1614 | return prepareContext; 1615 | } 1616 | // Need to cast to any here since TypeScript does not understand that _isPrepared being true implies that there 1617 | // is a prepare result. 1618 | const prepareResult: PrepareResult = data._prepareResult as any; 1619 | const modifications = methods.getContextModifications.call(this, prepareResult); 1620 | return applyModificationsCached(prepareContext, modifications, childrenData.modifiedContextCache, { 1621 | sameValue: prepareContext.sameValue 1622 | }); 1623 | } 1624 | 1625 | /** 1626 | * Iterates through the used context attributes of this component. 1627 | * @param callback Function to call for each used context attribute, with the used attribute name as an argument. 1628 | * Iteration is stopped when the callback function returns a truthy value. 1629 | * @returns Whether the callback function returned a truthy value. 1630 | */ 1631 | anyUsedContextAttributes(callback: (name: string) => boolean): boolean { 1632 | return anyUsedAttribute(this._reback._usedContextAttributes, callback); 1633 | } 1634 | 1635 | getParent(): AnyComponent | null { 1636 | return this._reback._renderParent || null; 1637 | } 1638 | 1639 | isMounted(): boolean { 1640 | return !!(this._reback.flags & FLAG_MOUNTED); 1641 | } 1642 | 1643 | isRoot(): boolean { 1644 | const data = this._reback; 1645 | return !!(data.flags & FLAG_MOUNTED) && !data._renderParent; 1646 | } 1647 | 1648 | isPrepared(): boolean { 1649 | return !!(this._reback.flags & FLAG_PREPARED); 1650 | } 1651 | 1652 | pending() { 1653 | if (DEBUG_REBACK) { 1654 | return new RenderPending({source: this}); 1655 | } 1656 | return new RenderPending(); 1657 | } 1658 | 1659 | eachChild(callback: (child: AnyComponent) => void) { 1660 | const childrenData = this._reback.childrenData; 1661 | if (childrenData) { 1662 | iterChildren(childrenData.renderedChildren, callback); 1663 | } 1664 | } 1665 | 1666 | mapChildren(callback: (child: AnyComponent) => R): R[] { 1667 | const result: R[] = []; 1668 | const childrenData = this._reback.childrenData; 1669 | if (childrenData) { 1670 | iterChildren(childrenData.renderedChildren, child => { 1671 | result.push(callback(child)); 1672 | }); 1673 | } 1674 | return result; 1675 | } 1676 | 1677 | allChildren(callback: (child: AnyComponent) => boolean) { 1678 | const childrenData = this._reback.childrenData; 1679 | if (!childrenData) { 1680 | return true; 1681 | } 1682 | return allChildren(childrenData.renderedChildren, callback); 1683 | } 1684 | 1685 | getRenderResult(): RenderResult | null { 1686 | return this._reback._renderResult; 1687 | } 1688 | 1689 | /** 1690 | * Sets an attribute, similar to Backbone's `set`. 1691 | * However, if this happens during this component's render phase, 1692 | * we don't schedule another render pass (which would normally happen when using `set`). 1693 | * @param attr 1694 | * @param value 1695 | */ 1696 | setDuringRender(attr: string, value: any) { 1697 | const data = this._reback; 1698 | data.flags |= FLAG_AVOID_RENDER_AFTER_RENDER; 1699 | this.set(attr, value); 1700 | data.flags &= ~FLAG_AVOID_RENDER_AFTER_RENDER; 1701 | } 1702 | 1703 | setStateDuringRender(attrs: Partial) { 1704 | const data = this._reback; 1705 | data.flags |= FLAG_AVOID_RENDER_AFTER_RENDER; 1706 | _setState(this, data, this.state, this.onChange, attrs as any); 1707 | data.flags &= ~FLAG_AVOID_RENDER_AFTER_RENDER; 1708 | } 1709 | 1710 | forceRender(options?: {notDuringRender?: boolean}) { 1711 | _forceRender(this, this._reback, (options && options.notDuringRender) || false); 1712 | } 1713 | 1714 | forcePrepare() { 1715 | _forcePrepare(this, this._reback); 1716 | } 1717 | 1718 | whenAttributeHasValue(name: string, value: any) { 1719 | return new SyncPromise(resolve => { 1720 | const currentValue = this.state[name]; 1721 | if (currentValue === value) { 1722 | resolve(); 1723 | } else { 1724 | const data = this._reback; 1725 | const stateWaiters = getUncommonData(data).stateWaiters; 1726 | let waiters = stateWaiters[name]; 1727 | if (!waiters) { 1728 | waiters = stateWaiters[name] = []; 1729 | } 1730 | waiters.push({value, resolve}); 1731 | } 1732 | }); 1733 | } 1734 | 1735 | /** 1736 | * Returns a promise that resolves to the component's context, 1737 | * once the component has received its context. 1738 | * *Note* that it is not safe to access attributes in that context 1739 | * and rely on automatic re-renders of the component when those attributes change. 1740 | * To establish a dependency on context attributes that automatically trigger 1741 | * re-renders, make sure to access them inside a prepare or render pass. 1742 | */ 1743 | whenContextReceived() { 1744 | const data = this._reback; 1745 | const context = data._context; 1746 | if (context) { 1747 | return SyncPromise.resolve(_getBoundContext(data)); 1748 | } else { 1749 | return this.whenRendered().then(() => _getBoundContext(data)); 1750 | } 1751 | } 1752 | 1753 | whenRendered() { 1754 | const data = this._reback; 1755 | return PromiseChain.whenDone(() => data._pendingRender || SyncPromise.resolve()); 1756 | } 1757 | 1758 | whenReady() { 1759 | const data = this._reback; 1760 | return PromiseChain.whenDone(() => data._prepare || SyncPromise.resolve()); 1761 | } 1762 | 1763 | whenReadyAndRendered() { 1764 | const data = this._reback; 1765 | return PromiseChain.whenDone(() => { 1766 | const ready = [data._prepare]; 1767 | if (data._pendingRender) { 1768 | ready.push(data._pendingRender); 1769 | } 1770 | return SyncPromise.all(ready); 1771 | }); 1772 | } 1773 | 1774 | whenAllReady() { 1775 | const data = this._reback; 1776 | return PromiseChain.whenDone(() => { 1777 | const ready: Array | null> = []; 1778 | _walkTree(this, data, component => { 1779 | ready.push(component._reback._prepare); 1780 | }); 1781 | return SyncPromise.all(ready); 1782 | }); 1783 | } 1784 | 1785 | whenAllReadyAndRendered() { 1786 | const data = this._reback; 1787 | // This is not simply a chain of `whenAllReady` and `whenRendered`, since rendering might 1788 | // change the ready state of the component. So we need to wrap the whole combination in `whenDone` 1789 | // which will check again after the promise resolves. 1790 | return PromiseChain.whenDone(() => { 1791 | const ready: Array | null> = []; 1792 | _walkTree(this, data, component => { 1793 | ready.push(component._reback._prepare); 1794 | }); 1795 | if (data._pendingRender) { 1796 | ready.push(data._pendingRender); 1797 | } 1798 | return SyncPromise.all(ready); 1799 | }); 1800 | } 1801 | 1802 | whenRenderedCompletely(): SyncPromise { 1803 | // If there is a pending render, wait for it and then resolve asynchronously (like all the other `when*` 1804 | // methods). This is important since the caller might expect a fully rendered component, but by the time 1805 | // `_pendingCompleteRender` resolves, the ancestors of this component are still in the process of rendering. 1806 | // (That would break e.g. the way NotebookLocate uses a chain of @requiresRender methods to retrieve the 1807 | // position of the cell to scroll to.) 1808 | return (this._reback._pendingCompleteRender || SyncPromise.resolve()).async(); 1809 | } 1810 | 1811 | isRenderedCompletely(): boolean { 1812 | const data = this._reback; 1813 | return !data._pendingCompleteRender || data._pendingCompleteRender.isSettled(); 1814 | } 1815 | 1816 | /** 1817 | * Sets state attributes on this component. Changing a state attribute causes the component to re-render 1818 | * and to re-prepare (depending on `shouldRender` and `shouldPrepare`, respectively). 1819 | * If a change handler is defined in the `onChange` property of the component, it is also invoked. 1820 | * This is essentially a faster alternative to Backbone attributes, with an API similar to React's `setState`. 1821 | * @param values Dictionary of values to set. Existing attributes that don't occur in the given values are 1822 | * left unchanged. 1823 | */ 1824 | setState(values: {[P in K]: State[P] | undefined}) { 1825 | _setState(this, this._reback, this.state, this.onChange, values as any); 1826 | } 1827 | 1828 | /** 1829 | * Triggers an event that (usually) bubbles up the component render tree. 1830 | * Event listeners are set up by defining an `onEvent` object on a component instance, 1831 | * mapping event names to handler functions. Event handlers are executed with the `this` context pointing 1832 | * to the component they are defined in, and they receive the payload defined by the event trigger and 1833 | * the triggering component as arguments. 1834 | * Components can also define an `onAnyEvent` method to handle all events. This method receives the event name 1835 | * as its first argument, before the payload and the triggering component. 1836 | * `triggerEvent` and `onEvent` should be preferred over Backbone's event mechanism when possible 1837 | * (especially in performance-critical code), since it avoids a lot of overhead with managing various lists of event 1838 | * listeners etc. 1839 | * @param name Name of the event to trigger. 1840 | * @param event Payload to pass to the event handler as its first argument. 1841 | * @param options Additional options: whether the event should bubble up or not. 1842 | */ 1843 | triggerEvent(name: string, event?: any, options?: {noBubble?: boolean}) { 1844 | let component: AnyComponent | null = this; 1845 | const noBubble = options && options.noBubble; 1846 | do { 1847 | const eventListener = component.onEvent && component.onEvent[name]; 1848 | if (eventListener) { 1849 | eventListener.call(component, event, this); 1850 | } 1851 | const anyEventListener = component.onAnyEvent; 1852 | if (anyEventListener) { 1853 | anyEventListener.call(component, name, event, this); 1854 | } 1855 | if (!component.shouldPropagateEvent(name, event)) { 1856 | break; 1857 | } 1858 | component = component.getParent(); 1859 | } while (component && !noBubble); 1860 | } 1861 | 1862 | shouldPropagateEvent(name: string, event: any) { 1863 | return true; 1864 | } 1865 | 1866 | set(...args: any) { 1867 | if (args.length >= 2) { 1868 | this.setState({[args[0]]: args[1]} as any); 1869 | } else { 1870 | this.setState(args[0]); 1871 | } 1872 | } 1873 | 1874 | get(name: string) { 1875 | return this.state[name]; 1876 | } 1877 | 1878 | useContextCachesFromComponent(otherComponent: AnyComponent) { 1879 | const data = this._reback; 1880 | const otherData = otherComponent._reback; 1881 | const otherChildrenData = otherData.childrenData; 1882 | if (otherChildrenData) { 1883 | const childrenData = getChildrenData(data); 1884 | childrenData.modifiedPrepareContextCache = otherChildrenData.modifiedPrepareContextCache; 1885 | childrenData.modifiedContextCache = otherChildrenData.modifiedContextCache; 1886 | } else { 1887 | const childrenData = data.childrenData; 1888 | if (childrenData) { 1889 | childrenData.modifiedPrepareContextCache = {}; 1890 | childrenData.modifiedContextCache = {}; 1891 | } 1892 | } 1893 | } 1894 | 1895 | setRenderRequestCallback(callback: () => any) { 1896 | const rootData = getRootData(this._reback); 1897 | rootData.onRequestRender = callback; 1898 | } 1899 | 1900 | // -------------------------------------------------- 1901 | // "Virtual protected" methods meant for overriding in subclasses 1902 | // (but don't call them directly) 1903 | // -------------------------------------------------- 1904 | 1905 | defaults(): Partial { 1906 | return {}; 1907 | } 1908 | 1909 | onAppear() {} 1910 | 1911 | onMount() {} 1912 | 1913 | onUnmount() {} 1914 | 1915 | onDisappear() {} 1916 | 1917 | getPrepareContextModifications(): {[name: string]: any} | null { 1918 | return null; 1919 | } 1920 | 1921 | getContextModifications(prepareResult: PrepareResult): {[name: string]: any} | null { 1922 | return null; 1923 | } 1924 | 1925 | canHashRenderArg(): boolean { 1926 | return false; 1927 | } 1928 | 1929 | getRenderArgHash(arg: RenderArgs): string { 1930 | return ''; 1931 | } 1932 | 1933 | getMaxRenderCacheSize(): number { 1934 | return 1; 1935 | } 1936 | 1937 | /** 1938 | * Called when the component receives a new context. 1939 | * @param prevContext The previous context. 1940 | * @deprecated Access the context in `doPrepare` or `doRender` instead, 1941 | * which establishes automatic dependencies on context attributes and thus 1942 | * leads to fewer surprises. 1943 | */ 1944 | onReceiveContext(prevContext?: ContextType) {} 1945 | 1946 | doPrepare(): any { 1947 | return null; 1948 | } 1949 | 1950 | doRender(arg: RenderArgs, prepareResult: PrepareResult): RenderResult | void {} 1951 | 1952 | doRenderPending(arg: RenderArgs): RenderResult | void {} 1953 | 1954 | doRenderError(arg: RenderArgs, error: any): RenderResult { 1955 | // By default, throw errors. 1956 | throw error; 1957 | } 1958 | 1959 | onCachedRender(renderResult: RenderResult) {} 1960 | 1961 | onAnyEvent(name: string, event: any, target: AnyComponent) {} 1962 | 1963 | shouldPrepare(changedAttributes: ReadonlySet) { 1964 | return changedAttributes.size > 0; 1965 | } 1966 | 1967 | shouldRender(arg: RenderArgs, prepareResult: PrepareResult) { 1968 | // When there is a cache for the given render argument, we use it by default. 1969 | // But components could override this to require a re-render even if there is a cached result. 1970 | return false; 1971 | } 1972 | 1973 | shouldWaitForChildren(): boolean { 1974 | return false; 1975 | } 1976 | 1977 | shouldInterruptRender(generation: number, time: number, components: number): boolean { 1978 | return false; 1979 | } 1980 | 1981 | interruptRendering() { 1982 | return RENDER_INTERRUPT; 1983 | } 1984 | 1985 | // -------------------------------------------------- 1986 | // Render methods 1987 | // -------------------------------------------------- 1988 | 1989 | renderRoot(arg?: RenderArgs, options: RenderOptions = {}) { 1990 | const data = this._reback; 1991 | // First, remove the caches of all components (and their ancestors) that have been interrupted before. 1992 | _rerenderInterrupted(data._id); 1993 | // When starting a (top-level) render pass, remember the previous global state and reset it. 1994 | // This is to support a top-level render pass "within" another top-level render pass. 1995 | const oldState = resetState(); 1996 | renderState.isRendering = true; 1997 | renderState.lastRenderWasInterrupted = !!(data.flags & FLAG_RENDER_ROOT_WAS_INTERRUPTED); 1998 | const rootData = getRootData(data); 1999 | renderState.lastRenderComponentCount = rootData.componentCount; 2000 | renderState.renderInterruptGeneration = rootData.interruptGeneration; 2001 | renderState.renderStartTime = now(); 2002 | try { 2003 | return this.render(arg, options); 2004 | } finally { 2005 | const isInterrupted = renderState.isRenderInterrupted; 2006 | if (isInterrupted) { 2007 | data.flags |= FLAG_RENDER_ROOT_WAS_INTERRUPTED; 2008 | } else { 2009 | data.flags &= ~FLAG_RENDER_ROOT_WAS_INTERRUPTED; 2010 | } 2011 | rootData.interruptGeneration = isInterrupted ? renderState.renderInterruptGeneration : 0; 2012 | rootData.componentCount = renderState.renderComponentCount; 2013 | restoreState(oldState); 2014 | } 2015 | } 2016 | 2017 | renderRootAsync(arg?: RenderArgs, options: RenderOptions = {}): SyncPromise { 2018 | const data = this._reback; 2019 | try { 2020 | return SyncPromise.resolve(this.renderRoot(arg, {...options, isRequired: true})); 2021 | } catch (e) { 2022 | if (e instanceof RenderPending) { 2023 | DEBUG_REBACK && logger.debug(d`Caught RenderPending while asynchronously rendering ${this}`); 2024 | return new SyncPromise((resolve, reject) => { 2025 | const rootData = getRootData(data); 2026 | const oldValue = rootData.onRequestRender; 2027 | rootData.onRequestRender = () => { 2028 | rootData.onRequestRender = oldValue; 2029 | DEBUG_REBACK && logger.debug(d`Asynchronously rerendering ${this}`); 2030 | this.renderRootAsync(arg, options).then(resolve, reject); 2031 | }; 2032 | }); 2033 | } 2034 | return SyncPromise.reject(e); 2035 | } 2036 | } 2037 | 2038 | renderRequired(arg?: RenderArgs, options: RenderOptions = {}) { 2039 | return this.render(arg, {...options, isRequired: true}); 2040 | } 2041 | 2042 | renderOptional(arg: RenderArgs, options: RenderOptions = {}) { 2043 | return this.render(arg, {...options, isOptional: true}); 2044 | } 2045 | 2046 | render(arg?: RenderArgs, options: RenderOptions = {}): RenderResult { 2047 | DEBUG_REBACK && logger.debug(d`Rendering ${this}`); 2048 | if (!renderState.isRendering) { 2049 | throw new Error( 2050 | `Rendering component ${this.toString()} outside \`Component.render\`. ` + 2051 | 'The outermost (root) component must be rendered using `Component.render(root)`.' 2052 | ); 2053 | } 2054 | const data = this._reback; 2055 | if (DEBUG_REBACK) { 2056 | data.debugRenderAnalysisData = {}; 2057 | } 2058 | _enterRender(this, data, options ? options.context : null); 2059 | return _render( 2060 | this, 2061 | data, 2062 | arg, 2063 | options ? options.context : null, 2064 | (options && options.isRequired) || false, 2065 | (options && options.isOptional) || false, 2066 | 0 2067 | ); 2068 | } 2069 | } 2070 | 2071 | if (DEBUG_REBACK || DEBUG || PROFILE_REBACK) { 2072 | // If we have any sort of debugging or profiling enabled, expose Reback's internal utility functions 2073 | // in the global scope so that we can easily experiment with them, e.g. querying their optimization status. 2074 | globals._rebackUtils = { 2075 | _childFinishedRender, 2076 | _doPrepare, 2077 | _doRender, 2078 | _enterRender, 2079 | _forceRender, 2080 | _invalidatePrepareCache, 2081 | _invalidateRenderCache, 2082 | _performRender, 2083 | _remount, 2084 | _render, 2085 | _requestRender, 2086 | _resetPendingRender, 2087 | _setState, 2088 | _unmountFromParent, 2089 | _unmountPreviousChildren, 2090 | _updateContext, 2091 | _useCache, 2092 | _walkTree, 2093 | allChildren, 2094 | iterChildren, 2095 | tryCatch0 2096 | }; 2097 | } 2098 | --------------------------------------------------------------------------------