diff --git a/text/0000-reparenting.md b/text/0000-reparenting.md new file mode 100644 index 00000000..78aa8e69 --- /dev/null +++ b/text/0000-reparenting.md @@ -0,0 +1,603 @@ +- Start Date: 2018-03-11 +- RFC PR: (leave this empty) +- React Issue: (leave this empty) + +# Summary + +When writing a component that contains a set of large subtrees that stay relatively the same, but are simply moved around such that React's virtual DOM diffing can't detect the movement, React will end up recreating huge trees it should simply be moving. + +The goal of this RFC is to introduce a "Reparent" API that allows portions of the React tree to be marked in a way that allows React to know when to move them from one part of the tree to another instead of deleting and recreating the DOM and component instances. + +# Basic example + +## Layout + +Using reparents to render a page layout that can change structure between desktop and mobile without causing the page contents and sidebar to be recreated from scratch. + +```js +class Layout extends PureComponent { + header = React.createReparent(); + content = React.createReparent(); + sidebar = React.createReparent(); + + render() { + const {isMobile, children} = this.props; + + const header = this.header( +
+ + +
+ ); + + const sidebar = this.sidebar( +
+ +
+ ); + + const content = this.content( + + {children} + + ); + + if ( isMobile ){ + return ( +
+ {header} + {content} + {sidebar} +
+ ); + } else { + return ( +
+ {header} +
+ {content} + {sidebar} +
+
+ ); + } + } +} +``` + +## Detach trees of dom nodes / components + +```js +class Foo extends Component { + componentDidMount() { + console.log('Mounted'); + } + componmentWillUnmount() { + console.log('Unmounted'); + } + render() { + return null; + } +} + +class DetachableTree extends Component { + reparent = React.createReparent(); + + render() { + const content = this.reparent(this.props.children); + return this.props.show && content; + } +} + +ReactDOM.render( + + + , + container); +// log: Mounted + +// Children can be detached from the dom tree with `show = false` +ReactDOM.render( + + + , + container); +// But they will not actually be unmounted + +// If you set `show = true` later, the previously rendered state tree will be re-inserted +ReactDOM.render( + + + , + container); + +// The state tree of the reparent is what is retained, not the contents. +// So if you change the contents of the DetachableTree, then components unmount as normal +ReactDOM.render( + + + , + container); +// log: Unmounted +// log: Mounted + +// But if you unmount the DetachableTree the reparent will unmount +// and all of the normal react elements inside will also unmount. +ReactDOM.render( +
, + container); +// log: Unmounted +``` + +## Table with movable cells + +Using dynamic reparents within a table to ensure that cells are not recreated from scratch when moved from one row to another. + +```js +class TableWidget extends PureComponent { + state = { + table: { + cols: 4, + rows: 0, + cells: [], + }, + }; + + addRow() { + this.setState(state => ({ + table: update(state.table, { + rows: state.table.rows + 1, + cells: { + [state.table.rows + 1]: { + $set: new Array(state.table.cols).fill(null), + }, + }, + }) + })); + } + + addData(row, col, data) { + this.setState(state => ({ + table: update(state.table, { + cells: { + [row]: { + [col]: { + $set: { + id: uuid(), + data, + }, + }, + }, + }, + }) + })); + } + + moveData(from, to) { + this.setState(state => { + let {table} = state; + // Set the to cell to the from cell's data + table = update(table, { + cells: { + [to.row]: { + [to.col]: { + $set: table.cells[from.row][from.col], + }, + }, + }, + }); + // Set the from cell to null + table = update(table, { + cells: { + [from.row]: { + [from.col]: { + $set: null, + }, + }, + }, + }); + return {table}; + }); + } + + removeData(row, col) { + this.setState(state => { + // Set the cell to null + let {table} = state; + table = update(table, { + cells: { + [row]: { + [col]: { + $set: null, + }, + }, + }, + }); + return {table}; + }); + } + + // Dynamic store for reparents, the reparents are created as-needed for cells in the table + cells = Object.create(null); + getCell = (cell, row, col) => { + this.cells[cell.id] = this.cells[cell.id] || React.createReparent(); + return this.cells[cell.id](); + }; + + render() { + const {table} = this.props + + return ( + + {table.cells.map((cols, row) => ( + + {row.cols.map((cell, column) => ( + + ))} + + ))} +
+ {this.getCell(cell, row, column)} +
+ ); + } +} +``` + +## Dynamic template + +Using reparents in a dynamic template/widget structure to avoid recreating widgets from scratch when they are move from one section of the template to another. + +```js +const TemplateWidgetReparentContext = React.createContext({}); + +class ReparentableTemplateWidget extends PureComponent { + static getDerivedStateFromProps(nextProps, prevState) { + if ( !prevState.widget || prevState.widgetType !== nextProps.widget ) { + const WidgetClass = getWidgetClassOfType(nextProps.widget); + return { + widgetType: nextProps.widget, + widget: new WidgetClass(), + }; + } + } + + render() { + const {widget} = this.state; + + return ( +
+ {widget.render()} +
+ ); + } +} + +/** + * This wrapper uses reparents from Template and the widget's own id + * to ensure that when a widget is moved from one section to another + * it is moved by react instead of recreated. + * + * This could be turned into an HOC + */ +class TemplateWidget extends Component { + render() { + const {id} = this.props; + + return ( + + {templateWidgetReparents => templateWidgetReparents[id]( + + )} + + ); + } +} + +class Section extends PureComponent { + render() { + const {title, widgets} = this.props; + + return ( +
+

{title}

+ {widgets.map(widget => ( + + ))} +
+ ); + } +} + +class Template extends PureComponent { + static getDerivedStateFromProps(nextProps, prevState) { + if ( nextProps.sections === prevState.sections ) return; + + const widgetIds = new Set(); + nextProps.sections.forEach(section => section.widgets.forEach(widget => widgetIds.add(widget.id))); + + const templateWidgetReparents = {}; + + for ( const id of widgetIds ) { + if ( prevState.templateWidgetReparents && prevState.templateWidgetReparents[id] ) { + templateWidgetReparents[id] = prevState.templateWidgetReparents[id]; + } else { + templateWidgetReparents[id] = React.createReparent(); + } + } + + return { + sections: nextProps.sections, + templateWidgetReparents, + }; + } + + render() { + const {sections, templateWidgetReparents} = this.state; + + // Make sure React detaches trees for reparents we are still using instead of unmounting them + for ( const reparent of Object.values(templateWidgetReparents) ) { + reparent.keep(); + } + + return ( + + {sections.map(section => { +
+ })} + + ); + } +} + +let templateData = { + sections: [ + { + id: 's-1', + title: 'Untitled 1', + widgets: [ + { + id: 'w-1', + widget: 'paragraph', + props: { + text: 'Lorem ipsum...' + } + } + ] + }, + { + id: 's-2', + title: 'Lorem ipsum', + widgets: [] + } + ] +}; + +ReactDOM.render(