Skip to content
academia.sh

Lesson 16 / 20

Bundle Size and Startup

When a deferred screen is loaded, the placeholder is unmounted and the screen's node is mounted. The lesson measures that lazy loading changes the time of mounting, not the fate.

Published Updated

In this lesson

  • Measures how deferring the code of a screen changes the number of modules loaded at launch
  • Shows that when the deferred screen is loaded, the placeholder is unmounted and the screen is mounted
  • Explains that lazy loading changes the time of mounting, not the fate
  • Separates a module staying loaded from a node being unmounted
Contents

The previous lesson regenerated the tree three times and no command came out. The screen was there from the start. In a real application, not every screen is there from the start, and the code of a screen does not have to be loaded from the start either. The application’s JS code lives in a bundle; at launch, that bundle has to be read and run. As the bundle grows, the launch also carries the code of screens the user will never open.

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}`);
}

In this lesson, loading the code of a screen is work outside the tree. When the code is loaded, a node enters the tree, and the fate looks at that node. Two files build this distinction. The first is the loader that loads the screen code on the first request and counts the loaded modules. Loading is deferred with a Promise; a module is loaded on the first request, and on later requests the same module is returned.

// loader.mjs — loads screen code on first use and counts loaded modules
export const loaded = { count: 0 };

const cache = new Map();

const MODULES = {
  Reports: () => Promise.resolve({ title: "Reports", rows: 3 }),
  Settings: () => Promise.resolve({ title: "Settings", rows: 2 }),
};

export function loadScreen(name) {
  if (!cache.has(name)) {
    cache.set(
      name,
      MODULES[name]().then((mod) => {
        loaded.count += 1;
        return mod;
      })
    );
  }
  return cache.get(name);
}

loadScreen loads each module on the first request and increments the counter. On the second request, the promise in the cache is returned; the counter does not increment. loaded.count is not in the tree and is not an input of the rule; the lesson keeps it only to show the number of loaded modules.

The second file is the function that produces a tree from the state. The application has a header and a content area. If the reports screen has not been loaded, a placeholder stands in the content area; if it has been loaded, the reports screen stands there.

// app.mjs — turns the app into a view tree
import { el } from "./fate.mjs";

export function renderApp(state) {
  const content = state.reports === null
    ? el("Placeholder", { text: "Loading" })
    : el("Reports", { rows: state.reports.rows });
  return el("App", {}, [el("Header", { title: "Dashboard" }), content]);
}

Deferred Screen at Launch

When the application launches, the reports screen has not been requested yet. The launch tree carries the placeholder; since the tree is built from scratch, reconcile(null, ...) gives every node as mounted. The number of loaded modules is zero.

// main.mjs
import { reconcile, printOps } from "./fate.mjs";
import { renderApp } from "./app.mjs";
import { loaded } from "./loader.mjs";

const first = renderApp({ reports: null });
console.log(`loaded modules: ${loaded.count}`);
printOps(reconcile(null, first));
loaded modules: 0
mounted: /App
mounted: /App/Header
mounted: /App/Placeholder

At launch, three nodes were mounted and no screen module was loaded. The code of the reports screen may sit in the bundle, but since it did not enter the tree and was not requested, it was no load at launch.

When It Loads

The user opened the reports screen. First the module is loaded, then the state is updated and the tree is produced again. In the new tree, there is no placeholder in the content area; there is the reports screen.

// main.mjs
import { reconcile, printOps } from "./fate.mjs";
import { renderApp } from "./app.mjs";
import { loadScreen, loaded } from "./loader.mjs";

const first = renderApp({ reports: null });
const mod = await loadScreen("Reports");
const second = renderApp({ reports: mod });
console.log(`loaded modules: ${loaded.count}`);
printOps(reconcile(first, second));
loaded modules: 1
kept: /App
kept: /App/Header
unmounted: /App/Placeholder
mounted: /App/Reports

The header and the root stayed kept. In the content area there were two separate types: the placeholder and the reports screen. Because the type changed, the rule unmounted the old one and mounted the new one without looking at the props. /App/Placeholder came out unmounted, and /App/Reports came out mounted. The reports screen is a node with no counterpart in the previous tree; it is not the next state of the placeholder.

Lazy loading changed the time of mounting here. The reports screen’s node was not mounted at launch; it was mounted at the moment the user asked for it. The same node could also have been mounted at launch with eager loading. That is measured separately.

Eager Loading

With eager loading, the code of both screens is loaded before launch. The reports screen is already in the first tree; since the tree is built from scratch, reconcile(null, ...) again gives only mounted.

// main.mjs
import { reconcile, countFates } from "./fate.mjs";
import { renderApp } from "./app.mjs";
import { loadScreen, loaded } from "./loader.mjs";

await Promise.all([loadScreen("Reports"), loadScreen("Settings")]);
console.log(`eager: loaded modules ${loaded.count}`);
const tree = renderApp({ reports: await loadScreen("Reports") });
countFates(reconcile(null, tree));
eager: loaded modules 2
kept: 0
updated: 0
mounted: 3
unmounted: 0
commands: 3

With eager loading, launch loaded two modules and mounted three nodes; with lazy loading, launch loaded zero modules and mounted three nodes. The three mounted nodes are the same in both measurements, but one of them is different: in the eager one the third node is the reports screen, in the lazy one it is the placeholder. Lazy loading did not reduce the command count in the tree; it moved the load from launch to the moment of the first request. The cost of this is that the user sees the placeholder at the first request.

In M12/K04, the question was whether a piece ran in this request. The same question can be asked for the screen here, but the fate does not look at that question. When a deferred screen is loaded, its node is mounted; “deferred” is not a fate. A deferred screen has no place in the tree; until it is loaded, there is a placeholder. The placeholder’s own fate is unmounted, and the screen’s fate is mounted.

How Deferral Looks in the Source

In a real application, there are two ways to defer a screen. The first is to define the screen with a deferred import and to show a placeholder until the screen is requested. The model does not run this snippet.

// Platform code: App
const Reports = React.lazy(() => import("./Reports"));

function App() {
  return (
    <Suspense fallback={<Placeholder />}>
      <Reports />
    </Suspense>
  );
}

Suspense holds the placeholder: as long as Reports is not ready, Placeholder stands in the tree, and when it is ready, Reports takes its place. This is the unmount and mount pair we saw in the measurement.

The second way is not to read the module until a button is pressed. Since the module is requested inside the function, running it is deferred until that point as well.

// Platform code: OpenReports
function OpenReports({ onOpen }) {
  const open = () => {
    const mod = require("./Reports");
    onOpen(mod.default);
  };
  return <Button title="Reports" onPress={open} />;
}

Both ways leave the loading or running of the code to the first use. Both mean less work at launch, and both bring a wait at the first use. How long the wait lasts is not in the model; that time is measured on a real device and with the real bundle.

Reading the Source Snippets Line by Line

In the first snippet, three lines do the work. React.lazy(() => import("./Reports")) does not read the screen right away; it only sets up a definition that says “fetch this module when needed”. <Suspense fallback={<Placeholder />}> says which node will stand in the tree until the screen is ready. <Reports /> is the real node wanted in the tree; as long as it is not ready, it does not enter the tree, and the placeholder stands in its place. In the model, loadScreen corresponds to the first line, and renderApp to the second and third lines.

In the second snippet, require("./Reports") is inside the function, and so it runs only when open is called. onOpen(mod.default) hands the loaded screen to the application; onPress={open} ties the call to the user’s touch. There is no tree-level placeholder here: until the button is pressed, neither the screen nor a placeholder is in the tree, and when it is pressed, the screen is mounted.

The difference between the two snippets changes the placeholder’s fate. In the first way, a placeholder is in the tree and comes out unmounted when the screen loads. In the second way, the placeholder is never mounted; the screen is directly mounted. In both ways, the lesson’s fate is the screen being mounted.

Why “Deferred” Is Not a Fate

It is useful to work the distinction above through with an example. At launch, since the screen has not been loaded, there is nothing in the tree at the path /App/Reports. The rule keeps no record for a node without a path; there is no record called “deferred” either. At the moment loading takes place, a new node appears in the tree and the rule sees it for the first time: mounted.

So deferral does not change the node’s fate; it only changes when the node will enter the tree. The node of a deferred screen gets no fate until the update in which it is mounted.

Common Misconceptions

The first misconception is that deferral makes the bundle smaller. Deferral changes when the code will be loaded; it does not change whether it is in the bundle. The screen’s code still enters the bundle, or comes alongside as a separate piece. Reducing the size is a separate job.

The second misconception is that deferring every screen is a gain. Deferral brings a wait at the first use. Deferring a screen the user will go to right away does not lighten launch; it only shifts the wait to another moment. The Eager Loading measurement shows this: when the code is loaded beforehand, the user sees no wait, but more work is done at launch.

The third misconception is that the placeholder is free in the tree. The placeholder is a node; it is mounted, and it is unmounted when the screen loads. Two commands go out at the moment of loading for this reason: one unmounts the placeholder, the other mounts the screen.

Bundle Size

The size of the bundle is made up of two things: the application’s own code and the code it imports. To reduce the size, first look at what is in the bundle. An import that is never used takes up space in the bundle; so does importing a whole library for a small piece of it. This lesson writes no size, because size is not in the model; tools that dump and inspect the contents of a real bundle do this job.

Startup, on the other hand, is a different measure from size. Two separate questions are asked: how many modules are loaded at launch, and when the first tree is mounted. A small bundle can contain many modules, and if all of them are run at launch, startup is still long. If most of the modules of a large bundle are deferred, startup can stay short. The loaded.count in the model corresponds to this second question: the number of loaded modules.

The Bounding Measurement

In the last measurement, the screen is loaded, shown, and then hidden. Two updates are counted: from the placeholder to the reports screen, and from the reports screen back to the placeholder. The number of loaded modules is also read after the updates. The rule is filtered to the reports screen’s path.

// main.mjs
import { reconcile, printOps } from "./fate.mjs";
import { renderApp } from "./app.mjs";
import { loadScreen, loaded } from "./loader.mjs";

const empty = renderApp({ reports: null });
const mod = await loadScreen("Reports");
const shown = renderApp({ reports: mod });
const ops = reconcile(empty, shown);
reconcile(shown, empty, "", ops);
printOps(ops.filter((op) => op.path === "/App/Reports"));
console.log(`loaded modules: ${loaded.count}`);
mounted: /App/Reports
unmounted: /App/Reports
loaded modules: 1

The focus node is /App/Reports. In the first update, the output is mounted: this is the lesson’s fate. In the second update, when the screen was hidden, the same node came out unmounted. The module count stayed at one: when the node was unmounted, the module load was not undone.

This shows the limit of deferral. Mounting and unmounting the node is the tree’s job; the module staying loaded is the loader’s job. The two are not tied to each other. In the model, hiding the screen does not undo the module load; the module stays loaded and is not loaded again at the second opening. At the second opening, the rule again counts the node as mounted, because the node was not in the tree. What the rule cannot see is that the second mounting does not need to load the module again.

PP3. The model builds this fate’s rule; the model is not the framework itself. In the model, module loading is deferred with a Promise, and a counter counts the loaded modules. In a real application, what is deferred is either a module that sits inside the bundle with its running deferred, or a piece loaded separately from the bundle; which one it is changes with the toolchain, and the model does not tell the two apart. The bundle size, the launch time, and the loading wait time are not in the model.

Summary

  • This lesson’s fate: mounted. The /App/Reports node entered the tree when the deferred screen was loaded and came out mounted; in the same update, the placeholder came out unmounted.
  • With lazy loading, launch loaded zero modules; with eager loading, it loaded two modules. In both launches, three nodes were mounted.
  • Lazy loading changes the time of mounting, not the fate: “deferred” is not a fate.
  • When the node was unmounted, the module stayed loaded; mounting and unmounting the node and loading the module are two separate jobs.
  • The rule does not see the bundle size or the wait time; these must be measured on a real device and with the real bundle.

Next Step

In this lesson the code of a screen could be loaded, and the tree could be mounted accordingly. What happens if an error occurs while the code is loading or running? The next lesson, Debugging Tools, measures an error boundary unmounting the broken subtree and mounting the fallback.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close