All files / lib/bundler treeshake.js

90.11% Statements 237/263
74.24% Branches 49/66
100% Functions 6/6
90.11% Lines 237/263

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 21824x         21824x 21824x 21824x 21824x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 1x 1x 1x 1x     1x 1x 1x 1x 1x 1x   1x     1x 1x 1x               1x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 278x 101x 101x 101x 101x 101x 101x 101x 48601x 48601x 48601x 15283x 15283x 101x 101x 101x 101x 32552x 6329x 6329x 101x 101x 101x 101x 101x 1x 1x 101x 101x 101x 101x 101x 101x 101x 101x 21824x 21824x 21824x 21823x 101x 101x 101x 9806x 9806x 9806x 9806x 9806x 9806x 9806x 2x 2x 2x 1x 1x 2x 2x 9804x 9804x 9804x 9806x 1x 1x 1x 1x 1x 1x 9804x 9804x 9804x 9804x 9804x 9804x 9804x 9804x 9804x 9804x 9804x 9804x 9806x 26960x 26960x 26960x 26960x 26960x 26960x 26960x 26960x 26960x 26960x 26960x 43436x 43436x 26960x 26960x 26960x 9804x 9804x 9806x 26494x 26494x 26494x 26494x 26494x 5489x 26494x 4940x 4940x 26494x 9804x 9806x 270x 270x 270x 270x 270x 270x           270x 270x 270x           9804x 101x 101x 101x 101x 101x 101x  
/**
 * @file treeshake.js
 * @description Decides which modules an application actually needs.
 *
 * ## What was impossible before, and why
 *
 * The build prepended `dist/runtime.min.js` — one pre-bundled blob — to every
 * application. Nothing about that arrangement admits the question "does this
 * application use the trace recorder?", because by the time the compiler ran,
 * the recorder had already been fused into a single artifact. Shaking is not a
 * feature that was missing; it was unaskable.
 *
 * With the runtime consumed as modules the question is answerable, and this
 * file answers it.
 *
 * ## The rule, and its one deliberate departure from the specification
 *
 * Two kinds of edge are treated differently, which is the whole mechanism:
 *
 * - **A plain `import` always executes its target.** That is what ES modules
 *   do, and the runtime depends on it: `reactive/proxyHandler.js` calls
 *   `setPathResolver(getPropertyPath)` at its top level, and that call has to
 *   happen. No analysis here will ever drop a module that something imports.
 *
 * - **A re-export edge is followed only for the names that are needed.** A
 *   barrel is a routing table, and `export { startRecording } from
 *   './trace/recorder.js'` in a barrel nobody asks `startRecording` of is a
 *   route to nowhere. Strictly, the specification says that re-export executes
 *   `recorder.js`; every bundler departs from it here, because otherwise no
 *   barrel is ever shakeable and `avenx-core/runtime` is a barrel.
 *
 * The departure is bounded rather than blanket. A re-export target is still
 * kept when it has a top-level side effect of its own — an expression statement
 * rather than a declaration — or when its package declares itself effectful
 * through `sideEffects` in `package.json`. Exactly one module in the Avenx
 * runtime has such a statement, and it is reached by a plain import anyway.
 *
 * ## What this does not do
 *
 * It does not remove unused *declarations inside* a module that is kept.
 * Statement-level elimination needs a real identifier analysis, and a
 * bundler that guesses at that miscompiles code rather than shrinking it.
 * The honest consequence is stated in the build's own reporting: what
 * shaking removes here is whole modules, and the fixed cost of a module that
 * something imports is its whole source.
 * @module lib/bundler/treeshake
 */
 
import fs from 'fs';
import path from 'path';
 
/**
 * The wildcard standing for "every export of this module is needed".
 * @type {string}
 */
const ALL = '*';
 
/**
 * Whether a module runs code at its top level beyond declaring things.
 *
 * An expression statement at module scope is a side effect: it happens when the
 * module is evaluated and nothing else will make it happen. A declaration is
 * not, even when its initialiser calls something — that is the standard
 * assumption every bundler makes, and abandoning it would keep every module
 * that ever writes `const x = new Thing()`.
 * @param {object} module - A graph module.
 * @returns {boolean} True when the module must run if it is reached at all.
 */
export function hasTopLevelEffects(module) {
  if (module.format !== 'esm') {
    // A CommonJS module's body is an assignment to `module.exports` and
    // whatever else it likes. Nothing here can tell those apart.
    return true;
  }
  return module.record.statements.some(
    (statement) => statement.kind === 'statement' && statement.declares.length === 0,
  );
}
 
/**
 * Reads the `sideEffects` declaration of the package a module belongs to.
 *
 * `"sideEffects": false` is the convention a package uses to say its modules
 * can be dropped when unused. It is honoured for third-party packages, where
 * this analysis has no other way to know.
 * @param {string} file - Absolute module path.
 * @param {Map<string, boolean|null>} cache - Per-directory answers.
 * @returns {boolean|null} False when the package declares itself pure, true
 *   when it declares effects, null when it says nothing.
 */
function packageSideEffects(file, cache) {
  let dir = path.dirname(file);
  for (;;) {
    if (cache.has(dir)) {
      return cache.get(dir);
    }
    const manifestPath = path.join(dir, 'package.json');
    if (fs.existsSync(manifestPath)) {
      let answer = null;
      try {
        const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
        if (manifest.sideEffects === false) answer = false;
        else if (manifest.sideEffects === true) answer = true;
      } catch {
        answer = null;
      }
      cache.set(dir, answer);
      return answer;
    }
    const parent = path.dirname(dir);
    if (parent === dir) {
      cache.set(dir, null);
      return null;
    }
    dir = parent;
  }
}
 
/**
 * Works out which modules the bundle must contain.
 * @param {object} options - Shake options.
 * @param {object} options.graph - The linked module graph.
 * @param {string[]} options.order - Modules in emission order.
 * @param {Map<string, string[]>} [options.entryNeeds] - Export names an entry's
 *   consumer requires, keyed by entry id. `['*']` means the whole namespace.
 * @returns {Set<string>} Module ids to emit.
 */
export function shake({ graph, order, entryNeeds = new Map() }) {
  /** @type {Map<string, Set<string>>} */
  const needed = new Map();
  /** @type {Set<string>} */
  const executed = new Set();
  const effectsCache = new Map();
 
  const need = (id, name) => {
    if (!needed.has(id)) needed.set(id, new Set());
    const set = needed.get(id);
    if (set.has(ALL) || set.has(name)) return false;
    set.add(name);
    return true;
  };
 
  const queue = [];
  const execute = (id) => {
    if (executed.has(id)) return;
    executed.add(id);
    queue.push(id);
  };
 
  for (const entry of graph.entries) {
    execute(entry);
    for (const name of entryNeeds.get(entry) || []) {
      need(entry, name);
    }
  }
 
  /**
   * Whether a module must be kept even though none of its exports are wanted.
   * @param {object} module - The graph module.
   * @returns {boolean} True when dropping it would change behaviour.
   */
  const mustKeep = (module) => {
    const declared = module.external ? packageSideEffects(module.id, effectsCache) : null;
    if (declared === false) return false;
    if (declared === true) return true;
    return hasTopLevelEffects(module);
  };
 
  while (queue.length > 0) {
    const id = queue.shift();
    const module = graph.modules.get(id);
    if (!module) continue;
 
    const wanted = needed.get(id) || new Set();
 
    if (module.format === 'cjs') {
      // Nothing about a CommonJS module's exports is static, so every module it
      // requires is needed.
      for (const target of module.resolved.values()) {
        execute(target);
      }
      continue;
    }
 
    // A dynamic import needs the whole namespace: nothing here can say which
    // member the awaiting code will read.
    for (const entry of module.record.dynamicImports) {
      const target = entry.specifier === null ? null : module.resolved.get(entry.specifier);
      if (!target) continue;
      const walked = executed.has(target);
      execute(target);
      if (need(target, ALL) && walked) queue.push(target);
    }
 
    // A plain import executes its target, exactly as the language says.
    //
    // Wanting a name from a module that has *already* been walked has to put it
    // back on the queue. A module resolves its own re-exports only while it is
    // being walked, so a name first wanted after that point would never be
    // followed to the module that actually defines it -- the re-export would be
    // dropped and the importer would destructure `undefined` at run time. The
    // re-export loop below has always re-queued for this reason; these two did
    // not, and the difference only showed once a generated component module --
    // which is linked after the runtime it imports from -- became the first
    // consumer of a re-exported name.
    for (const entry of module.record.imports) {
      const target = module.resolved.get(entry.specifier);
      if (!target) continue;
      const walked = executed.has(target);
      execute(target);
 
      // `need` is called for its effect on every branch, so the accumulator is
      // written this way round: `added || need(...)` would stop calling it.
      let added = false;
      if (entry.namespace) added = need(target, ALL) || added;
      if (entry.defaultLocal) added = need(target, 'default') || added;
      for (const binding of entry.bindings) {
        added = need(target, binding.imported) || added;
      }
 
      if (added && walked) queue.push(target);
    }
 
    // A re-export is followed only for the names something asked for.
    for (const entry of module.record.reExports) {
      const target = module.resolved.get(entry.specifier);
      if (!target) continue;
      const targetModule = graph.modules.get(target);
      const asked = wanted.has(ALL) || wanted.has(entry.exported);
      if (!asked && targetModule && !mustKeep(targetModule)) continue;
      execute(target);
      if (asked) {
        if (need(target, entry.imported) && executed.has(target)) queue.push(target);
      }
    }
 
    for (const entry of module.record.starReExports) {
      const target = module.resolved.get(entry.specifier);
      if (!target) continue;
      const targetModule = graph.modules.get(target);
      if (!targetModule) continue;
 
      if (wanted.has(ALL)) {
        execute(target);
        need(target, ALL);
        queue.push(target);
        continue;
      }
 
      const shared = [...wanted].filter((name) => targetModule.exportNames.has(name));
      if (shared.length === 0 && !mustKeep(targetModule)) continue;
      execute(target);
      for (const name of shared) {
        if (need(target, name)) queue.push(target);
      }
    }
  }
 
  // Preserve emission order rather than discovery order: the emitter relies on
  // dependencies coming first, and that property belongs to the topological
  // sort, not to this traversal.
  return new Set(order.filter((id) => executed.has(id)));
}