Skip to content
academia.sh

Lesson 20 / 20

Build and Release

In development, fast refresh updates the node in place, while a full reload unmounts the root. The lesson measures this difference and gathers the fates of twenty lessons in a single table.

Published Updated

In this lesson

  • Measures that fast refresh leaves the node updated or kept and that the state lives on
  • Shows that in a full reload the whole of the old root is unmounted and the new one is mounted
  • Explains that fast refresh also loses state when the root type changes, and that build, signing, and release channels are not visible in the tree
  • Gathers the fates of twenty lessons in the wrap-up table and counts the distribution
Contents

The previous lesson measured that a native module is a single node in the tree. Once native code is written, the application stops being only JavaScript: the platform side is compiled, signed, and goes to the app store. This lesson is the course’s wrap-up. First it looks at what happens in the tree when a component file changes during development, then it briefly separates the release pipeline, and at the end it gathers the fates of twenty lessons in a single table.

The course’s question: what does this update do to the node in the view tree? The answers are kept, updated, mounted, and unmounted; the sum of everything other than kept is the commands. The rule is in the core file.

// fate.mjs — classifies what an update does to each node of the view tree
export const FATES = ["kept", "updated", "mounted", "unmounted"];

export function el(type, props = {}, children = [], key = null) {
  return { type, props, children, key };
}

function segment(node) {
  return node.key === null ? node.type : `${node.type}[${node.key}]`;
}

function identity(node, index) {
  return node.key === null ? `#${index}` : `key:${node.key}`;
}

function sameProps(a, b) {
  const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
  return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n]));
}

function whole(node, path, fate, ops) {
  const here = `${path}/${segment(node)}`;
  ops.push({ fate, path: here });
  node.children.forEach((child) => whole(child, here, fate, ops));
}

export function reconcile(prev, next, path = "", ops = []) {
  if (prev === null) {
    whole(next, path, "mounted", ops);
    return ops;
  }
  if (next === null) {
    whole(prev, path, "unmounted", ops);
    return ops;
  }
  if (prev.type !== next.type || prev.key !== next.key) {
    whole(prev, path, "unmounted", ops);
    whole(next, path, "mounted", ops);
    return ops;
  }
  const here = `${path}/${segment(next)}`;
  ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here });
  const before = new Map(prev.children.map((child, i) => [identity(child, i), child]));
  const matched = new Set();
  next.children.forEach((child, i) => {
    const id = identity(child, i);
    matched.add(id);
    reconcile(before.has(id) ? before.get(id) : null, child, here, ops);
  });
  prev.children.forEach((child, i) => {
    if (!matched.has(identity(child, i))) reconcile(child, null, here, ops);
  });
  return ops;
}

export function printOps(ops) {
  ops.forEach((op) => console.log(`${op.fate}: ${op.path}`));
}

export function countFates(ops) {
  for (const name of FATES) {
    console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`);
  }
  console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
}

The model of the development session is in a separate file. The screen’s source is the source object: the type of the root node and the title text. buildTree builds the tree from the source. The counter is the session’s state and enters the tree as a prop of the Counter node. There are two ways of loading. fastRefresh changes the source but keeps the root key. fullReload increments the root key by one and resets the counter.

// dev.mjs — model of the development session
import { el, reconcile } from "./fate.mjs";

export function buildTree(source, count, rootKey) {
  return el(source.root, {}, [
    el("Header", { title: source.title }),
    el("Counter", { count }),
  ], rootKey);
}

export function openSession(source) {
  return { source, count: 0, rootKey: null, tree: buildTree(source, 0, null) };
}

export function press(session) {
  const count = session.count + 1;
  return { ...session, count, tree: buildTree(session.source, count, session.rootKey) };
}

export function fastRefresh(session, source) {
  const draft = buildTree(source, session.count, session.rootKey);
  const ops = reconcile(session.tree, draft);
  const lost = ops.some((op) => op.fate === "unmounted" && op.path.endsWith("/Counter"));
  const count = lost ? 0 : session.count;
  const tree = lost ? buildTree(source, 0, session.rootKey) : draft;
  return [{ ...session, source, count, tree }, ops];
}

export function fullReload(session, source) {
  const rootKey = (session.rootKey ?? 0) + 1;
  const tree = buildTree(source, 0, rootKey);
  return [{ source, count: 0, rootKey, tree }, reconcile(session.tree, tree)];
}

The model’s only rule is that state is tied to identity: the counter lives only if the Counter node got past the update and kept its identity. fastRefresh tests this with lost. Instead of storing or copying the state separately, it looks at the tree’s comparison result.

First Launch

When the session is first opened, there is no previous tree. Every node is mounted and the counter is zero.

// main.mjs
import { reconcile, printOps } from "./fate.mjs";
import { openSession } from "./dev.mjs";

const session = openSession({ root: "App", title: "Home screen" });
printOps(reconcile(null, session.tree));
console.log(`counter: ${session.count}`);
mounted: /App
mounted: /App/Header
mounted: /App/Counter
counter: 0

Fast Refresh

The user presses the counter three times, then the developer changes the title and saves the file. Fast refresh reloads only the changed component file. The root type and key stay the same.

// main.mjs
import { printOps } from "./fate.mjs";
import { openSession, press, fastRefresh } from "./dev.mjs";

let session = openSession({ root: "App", title: "Home screen" });
session = press(press(press(session)));
const [after, ops] = fastRefresh(session, { root: "App", title: "Start" });
printOps(ops);
console.log(`counter: ${after.count}`);
kept: /App
updated: /App/Header
kept: /App/Counter
counter: 3

The title came out updated; the root and the counter stayed kept. A single command went out and the counter stopped at three: the refresh did not touch the identity of the nodes, so the state lived. A save that did not change the title at all would leave all three nodes kept. Fast refresh is the most frequent pair of fates in development: the changed node is updated, the rest are kept.

Full Reload

Not every change fits fast refresh. If a file imported from outside the render tree changes, for example the application’s startup code, the whole bundle is loaded from scratch; in the model the trace of this is the root key increasing. Adding a native module goes even beyond this: besides the bundle, it requires the platform side to be rebuilt.

// main.mjs
import { countFates } from "./fate.mjs";
import { openSession, press, fullReload } from "./dev.mjs";

let session = openSession({ root: "App", title: "Home screen" });
session = press(press(press(session)));
const [after, ops] = fullReload(session, { root: "App", title: "Home screen" });
countFates(ops);
console.log(`counter: ${after.count}`);
kept: 0
updated: 0
mounted: 3
unmounted: 3
commands: 6
counter: 0

Although the source did not change, the result is the opposite of fast refresh: three nodes unmounted, three nodes mounted, none kept. Six commands, the counter zero. When the root key changes, the comparison does not go below the root: the root is a new node, the old one disappears, and every node under it goes with it.

Fast Refresh Can Break Too

Fast refresh is not a privilege but a condition. If the root type changes, the comparison cannot match the root. If the developer changes the name of the root component, fast refresh also unmounts the whole tree.

// main.mjs
import { countFates } from "./fate.mjs";
import { openSession, press, fastRefresh } from "./dev.mjs";

let session = openSession({ root: "App", title: "Home screen" });
session = press(press(press(session)));
const [after, ops] = fastRefresh(session, { root: "Shell", title: "Home screen" });
countFates(ops);
console.log(`counter: ${after.count}`);
kept: 0
updated: 0
mounted: 3
unmounted: 3
commands: 6
counter: 0

The output is the same as the full reload, but the path is different: here it is not the root key but the root type that changed. The two paths arrive at the same fate. Whether the state lives depends not on which command was used but on whether the root node’s identity was kept.

The Release Pipeline

In development, the bundle comes from the development server. In release, the bundle is embedded in the application and goes through two separate paths on two platforms. On each platform the build has three steps: compiling, signing, distribution. Compiling brings the JavaScript bundle and the native code together. Signing proves who produced the package; both platforms have their own key management. Distribution puts the signed package into a release channel: an internal test, a limited test, or a public release.

// Platform code: release configuration (Android)
buildTypes {
  release {
    isMinifyEnabled = true
    signingConfig = signingConfigs.getByName("release")
  }
}

The snippet shows two distinctions. The release block says that the release build is a separate configuration from the development build, and the signingConfig line says that the signature is a part of the build step. On the iOS side the same job is done in the project settings; the model does not separate this, because the tree does not see which key signed it.

There is one more channel: the over-the-air update. The JavaScript bundle can be changed independently of the app store release. The limit is this: only the JavaScript side changes remotely. A new version of a native module, a new permission, or a new native view requires a new store release. For this reason the NativeMap node of the previous lesson can be rewritten with an over-the-air update, but the map’s native code cannot.

Common Misconceptions

The first misconception is that fast refresh always keeps the state. The root type measurement above showed the opposite: when the root type changed, the state was lost. Fast refresh is not a guarantee but a result that depends on the tree’s comparison result.

The second misconception is that a full reload is restarting the application. The model does not restart the application, it builds the root again. Whether the phone’s process stays up or not is outside the tree rule; the rule only says what happens to the nodes in the tree.

The third misconception is that the release configuration is reflected in the tree. The build type, the signature, and the release channel do not change the type or the props of the nodes. The same source produces the same tree in both the development and the release bundle; what differs is where the bundle that reaches the tree comes from.

The Bounding Measurement

The distinguishing question of this lesson is the question where does this change reach (M12/K02). That question asks about the propagation of a code change: in place, linked, breaking, or implicit. This course classifies not the code but the effect of a single update at run time on a single node. In fast refresh the code change propagates in place; the trace it leaves in the tree is updated at the title node and kept in the rest. The answers to the two questions are independent of each other.

The last measurement shows the old root. The full reload is done at the root; the rule is filtered only to the records at the root level.

// main.mjs
import { printOps } from "./fate.mjs";
import { openSession, press, fullReload } from "./dev.mjs";

let session = openSession({ root: "App", title: "Home screen" });
session = press(press(press(session)));
const [after, ops] = fullReload(session, { root: "App", title: "Home screen" });
printOps(ops.filter((op) => op.path.split("/").length === 2));
console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
unmounted: /App
mounted: /App[1]
commands: 6

The focus node is the old root /App. It came out unmounted; the new one was mounted with the key /App[1]. When the old root went, the title and the counter under it also received the unmounted record: six commands. An unmounted node loses its identity, that is, the counter went from three to zero. An updated root would have kept its identity.

PP7. The model builds this fate’s rule; the model is not the framework itself. In the model, fast refresh builds the tree from the source from scratch and compares; a real fast refresh re-evaluates only the changed file and applies its own rules to keep the state. In the model the root key is incremented by hand; a real full reload loads the bundle from scratch, and its cost, and the connection of the development server, are outside the model. The release pipeline is not in the model either: the build, signing, and store processes are not inputs of the tree rule.

Closing Table

During the course, twenty updates were measured. Each row gives the fate of a lesson and the node focused on in that lesson’s bounding measurement. The fate cells are taken from the Summary section of the source lesson.

Lesson Measured update Fate Bounding measurement
What Is React Native a counter increments in a variable that does not enter the tree kept /Screen/Text, commands 0
Environment Setup the first update after the shell mounted /App/Home, with its subtree
Core Components a placeholder is replaced with an image unmounted /Card/Text
The Style System a style object with the same content arrives kept /Row/Box, commands 0
Touch and Interaction the pressed state becomes a prop updated /Screen/Button
Platform-Specific Code the trees of the two platforms are compared mounted /Settings/Toggle
Navigation a new route is pushed kept /Stack/Screen[list]
State Management the cart count changes updated /Screen/Header/Badge
Networking and Data data arrives unmounted /Feed/Spinner
Local Storage a draft is read at launch mounted /Editor/Draft
Secure Storage the session closes unmounted /App/Account
Notifications and Deep Linking a link is opened mounted /Stack/Screen[order-42]
Gestures and Animations the gesture continues on the native side kept /Deck/Card, commands 0
List Performance an element is added at the start updated /Inbox/Row
Frame Rate and Profiling the tree is produced again three times kept /Screen/Chart
Bundle Size and Startup a deferred screen is loaded mounted /App/Reports
Debugging Tools a subtree raises an error unmounted /Screen/Panel
Testing the button is pressed updated /Counter/Text
Writing Native Modules the map center changes updated /Screen/NativeMap, commands 1
Build and Release a full reload unmounted /App, commands 6

The distribution of the lessons: kept 5 (What Is React Native, The Style System, Navigation, Gestures and Animations, Frame Rate and Profiling), updated 5 (Touch and Interaction, State Management, List Performance, Testing, Writing Native Modules), mounted 5 (Environment Setup, Platform-Specific Code, Local Storage, Notifications and Deep Linking, Bundle Size and Startup), unmounted 5 (Core Components, Networking and Data, Secure Storage, Debugging Tools, Build and Release).

This distribution is not the same thing as the opening count of the core file. The core’s opening count counts the eight nodes of a single update: kept 5, updated 1, mounted 1, unmounted 1, and commands 3. The table, on the other hand, counts the fate of each of the twenty lessons. One counts nodes, the other counts lessons.

Summary

  • This lesson’s fate: unmounted. In the full reload, the old root /App came out unmounted because the root key changed; the new one became mounted, and with the three nodes under it there were six commands.
  • Fast refresh leaves the changed node updated and the rest kept; the state lives. When the root type or key changes, the state is lost.
  • Build, signing, and release channel are not visible in the tree; the over-the-air update changes only the JavaScript side.
  • The fates of the twenty lessons are distributed five by five: five lessons each for kept, updated, mounted, and unmounted.

Next Step

This course measured a framework that passes shared code to the platform’s native views as a description. The next course takes up a framework that does not leave the screen to the platform’s views but draws it with its own rendering engine.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close