@graftland/hmr 0.2.0-beta.0 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  // Node's synchronous module hooks have no Effect equivalent.
2
- import { Base64 } from "effect/encoding"
2
+ import { digest } from "./digest"
3
3
  import { registerHooks } from "node:module"
4
4
  import { fileURLToPath, pathToFileURL } from "node:url"
5
5
  import {
6
6
  Array,
7
+ Chunk,
7
8
  HashMap,
8
9
  HashSet,
9
10
  Option,
@@ -31,11 +32,12 @@ export const isMissingEntry = (url: string, cause: unknown): boolean =>
31
32
  // Allocated only when a session starts; importing this module performs no I/O.
32
33
  let sessions = 0
33
34
 
35
+ /** A resolution view that allocates fresh URLs for new or invalidated instances. */
34
36
  export interface Generation {
35
37
  readonly id: number
36
38
  }
37
39
 
38
- /** Bytes actually read by a particular native import attempt. */
40
+ /** Digest of the bytes actually loaded by a concrete module instance. */
39
41
  export interface FileVersion {
40
42
  readonly generation: number
41
43
  readonly content: string | undefined
@@ -49,7 +51,7 @@ export interface Attempt {
49
51
  }
50
52
 
51
53
  interface Dependency extends Attempt {
52
- readonly parent: string
54
+ readonly node: string
53
55
  }
54
56
 
55
57
  /** Resolve local requests lexically; Node's cached physical target is a separate fact. */
@@ -71,7 +73,6 @@ const fileRequest = (
71
73
  export const modules = (base: string, include: (url: string) => boolean) => {
72
74
  const key = `graft-hmr-${++sessions}`
73
75
  const prefix = `${key}:`
74
- /** What `request` produces: the generation that imports a specifier. */
75
76
  const decodeRequest = Schema.decodeUnknownOption(
76
77
  Schema.TemplateLiteralParser([
77
78
  prefix,
@@ -83,213 +84,256 @@ export const modules = (base: string, include: (url: string) => boolean) => {
83
84
  let revision = 0
84
85
  let importing:
85
86
  { readonly generation: Generation; readonly reload: boolean } | undefined
86
- // Dependency facts outlive an import operation: apply may import modules later.
87
- let dependencies = HashMap.empty<string, HashSet.HashSet<string>>()
88
- let edges = HashMap.empty<
89
- string,
90
- HashMap.HashMap<string, HashMap.HashMap<string, Dependency>>
91
- >()
92
- const link = (
93
- id: string,
94
- parent: string,
95
- child: string,
96
- request?: string,
97
- ) => {
98
- const graph = Option.getOrElse(
99
- HashMap.get(edges, id),
100
- HashMap.empty<string, HashMap.HashMap<string, Dependency>>,
101
- )
102
- const children = Option.getOrElse(
103
- HashMap.get(graph, parent),
104
- HashMap.empty<string, Dependency>,
105
- )
106
- edges = HashMap.set(
107
- edges,
108
- id,
109
- HashMap.set(
110
- graph,
111
- parent,
112
- HashMap.set(children, request ?? child, {
113
- generation: { id: Number(id) },
114
- parent,
115
- url: child,
116
- ...(request === undefined ? {} : { request }),
117
- }),
87
+ let resolved: string | undefined
88
+ // Resolution views contain only routing identities. Old code can execute after
89
+ // replacement (including teardown), so these persistent maps live until close.
90
+ // Cached instances also retain their facts: Node does not replay static imports.
91
+ // Leases restrict observation, not the lifetime of old routing targets.
92
+ let views = HashMap.empty<number, HashMap.HashMap<string, string>>()
93
+ let edges = HashMap.empty<string, HashMap.HashMap<string, Dependency>>()
94
+ let sources = HashMap.empty<string, string>()
95
+ let dependencies = HashSet.empty<string>()
96
+ let reachCache = HashMap.empty<string, HashSet.HashSet<string>>()
97
+ let retained = HashSet.empty<string>()
98
+ let instantiatedViews = HashSet.empty<number>()
99
+ const clean = (url: string): string => {
100
+ const value = new URL(url)
101
+ value.searchParams.delete(key)
102
+ return value.href
103
+ }
104
+ const version = (url: string | undefined): number | undefined => {
105
+ if (url === undefined) return undefined
106
+ const value = new URL(url).searchParams.get(key)
107
+ return value === null ? undefined : Number(value)
108
+ }
109
+ const tag = (url: string, id: number) => {
110
+ const value = new URL(url)
111
+ value.searchParams.set(key, String.String(id))
112
+ return value.href
113
+ }
114
+ const route = (url: string, id: number): string =>
115
+ Option.getOrElse(
116
+ HashMap.get(
117
+ Option.getOrElse(HashMap.get(views, id), HashMap.empty<string, string>),
118
+ url,
118
119
  ),
120
+ () => tag(url, id),
119
121
  )
120
- }
121
- const reachable = (id: string, roots: ReadonlyArray<string>) => {
122
- const graph = Option.getOrElse(
123
- HashMap.get(edges, id),
124
- HashMap.empty<string, HashMap.HashMap<string, Dependency>>,
122
+
123
+ const root = (attempt: Attempt) => route(attempt.url, attempt.generation.id)
124
+ const children = (node: string) =>
125
+ Option.getOrElse(
126
+ HashMap.get(edges, node),
127
+ HashMap.empty<string, Dependency>,
125
128
  )
129
+ const reachable = (node: string) => {
130
+ const cached = HashMap.get(reachCache, node)
131
+ if (Option.isSome(cached)) return cached.value
126
132
  let seen = HashSet.empty<string>()
127
- let pending = roots
128
- while (Array.isReadonlyArrayNonEmpty(pending)) {
129
- const [url, ...rest] = pending
130
- pending = rest
131
- if (HashSet.has(seen, url)) continue
132
- seen = HashSet.add(seen, url)
133
- pending = Array.appendAll(
134
- pending,
135
- Array.map(
136
- HashMap.toValues(
137
- Option.getOrElse(
138
- HashMap.get(graph, url),
139
- HashMap.empty<string, Dependency>,
140
- ),
141
- ),
142
- (edge) => edge.url,
143
- ),
144
- )
133
+ let pending: Chunk.Chunk<string> = Chunk.of(node)
134
+ while (Chunk.isNonEmpty(pending)) {
135
+ const current = Chunk.headNonEmpty(pending)
136
+ pending = Chunk.drop(pending, 1)
137
+ if (HashSet.has(seen, current)) continue
138
+ seen = HashSet.add(seen, current)
139
+ for (const edge of HashMap.values(children(current)))
140
+ pending = Chunk.prepend(pending, edge.node)
145
141
  }
142
+ reachCache = HashMap.set(reachCache, node, seen)
146
143
  return seen
147
144
  }
148
- // Both checks and polling derive resolution facts from the same owned graph.
149
- const resolutionsFor = (attempt: Attempt): ReadonlyArray<Attempt> => {
150
- const id = String.String(attempt.generation.id)
151
- const used = reachable(id, [attempt.url])
152
- const graph = Option.getOrElse(
153
- HashMap.get(edges, id),
154
- HashMap.empty<string, HashMap.HashMap<string, Dependency>>,
155
- )
156
- return [
157
- ...(attempt.request === undefined ? [] : [attempt]),
158
- ...Array.flatMap(HashMap.toValues(graph), (children) =>
159
- Array.filter(
160
- HashMap.toValues(children),
161
- (edge) =>
162
- edge.request !== undefined && HashSet.has(used, edge.parent),
163
- ),
164
- ),
165
- ]
145
+ const usedBy = (attempts: ReadonlyArray<Attempt>) => {
146
+ const roots = HashSet.fromIterable(Array.map(attempts, root))
147
+ let used = HashSet.empty<string>()
148
+ for (const node of roots) used = HashSet.union(used, reachable(node))
149
+ return used
166
150
  }
167
-
168
- let sources = HashMap.empty<string, HashMap.HashMap<string, string>>()
169
- let resolved: string | undefined
170
-
171
- const prepare = (): Generation => ({
172
- id: ++revision,
173
- })
174
- const clean = (url: string): string => {
175
- const value = new URL(url)
176
- value.searchParams.delete(key)
177
- return value.href
151
+ const link = (parent: string, node: string, request?: string) => {
152
+ if (
153
+ importing === undefined &&
154
+ !HashSet.has(retained, parent) &&
155
+ !HashSet.has(instantiatedViews, version(parent)!)
156
+ )
157
+ return
158
+ retained = HashSet.add(retained, node)
159
+ const url = clean(node)
160
+ const edge = {
161
+ node,
162
+ url,
163
+ generation: { id: version(node)! },
164
+ ...(request === undefined ? {} : { request }),
165
+ }
166
+ const previous = HashMap.get(children(parent), request ?? url)
167
+ if (
168
+ Option.exists(
169
+ previous,
170
+ (previous) => previous.node === node && previous.request === request,
171
+ )
172
+ )
173
+ return
174
+ edges = HashMap.set(
175
+ edges,
176
+ parent,
177
+ HashMap.set(children(parent), request ?? url, edge),
178
+ )
179
+ reachCache = HashMap.empty()
178
180
  }
179
- const version = (url: string | undefined): string | null =>
180
- url === undefined ? null : new URL(url).searchParams.get(key)
181
+ const resolutions = (
182
+ attempts: ReadonlyArray<Attempt>,
183
+ ): ReadonlyArray<Attempt> => [
184
+ ...Array.filter(attempts, (attempt) => attempt.request !== undefined),
185
+ ...Array.flatMap(Array.fromIterable(usedBy(attempts)), (node) =>
186
+ Array.filter(
187
+ HashMap.toValues(children(node)),
188
+ (edge) => edge.request !== undefined,
189
+ ),
190
+ ),
191
+ ]
181
192
  const unsupported = (url: string, format: string | null | undefined) =>
182
193
  format === "commonjs" ||
183
194
  format === "commonjs-typescript" ||
184
195
  format === "addon" ||
185
196
  String.endsWith(".node")(new URL(url).pathname)
186
- const requireRestart = (url: string, id: string) => {
187
- dependencies = HashMap.set(
188
- dependencies,
189
- id,
190
- HashSet.add(
191
- Option.getOrElse(HashMap.get(dependencies, id), HashSet.empty<string>),
192
- url,
193
- ),
194
- )
195
- if (
196
- importing !== undefined &&
197
- String.String(importing.generation.id) === id &&
198
- importing.reload
199
- )
200
- throw RestartRequired.make({ url })
197
+ const requireRestart = (node: string) => {
198
+ if (importing !== undefined || HashSet.has(retained, node))
199
+ dependencies = HashSet.add(dependencies, node)
200
+ if (importing?.reload === true && importing.generation.id === version(node))
201
+ throw RestartRequired.make({ url: clean(node) })
201
202
  }
202
-
203
- const record = (
204
- url: string,
205
- id: string,
206
- format: string | null | undefined,
203
+ const record = (node: string, format: string | null | undefined) => {
204
+ if (unsupported(clean(node), format)) requireRestart(node)
205
+ }
206
+ // One reverse traversal closes cycles and all importers of every dirty instance.
207
+ const invalidated = (
208
+ attempts: ReadonlyArray<Attempt>,
209
+ dirty: HashSet.HashSet<string>,
207
210
  ) => {
208
- if (unsupported(url, format)) requireRestart(url, id)
211
+ const used = usedBy(attempts)
212
+ let parents = HashMap.empty<string, HashSet.HashSet<string>>()
213
+ let pending = Chunk.empty<string>()
214
+ for (const node of used) {
215
+ if (HashSet.has(dirty, clean(node)))
216
+ pending = Chunk.prepend(pending, node)
217
+ for (const edge of HashMap.values(children(node))) {
218
+ parents = HashMap.set(
219
+ parents,
220
+ edge.node,
221
+ HashSet.add(
222
+ Option.getOrElse(
223
+ HashMap.get(parents, edge.node),
224
+ HashSet.empty<string>,
225
+ ),
226
+ node,
227
+ ),
228
+ )
229
+ if (edge.request !== undefined && HashSet.has(dirty, edge.request))
230
+ pending = Chunk.prepend(pending, edge.node)
231
+ }
232
+ }
233
+ for (const attempt of attempts)
234
+ if (attempt.request !== undefined && HashSet.has(dirty, attempt.request))
235
+ pending = Chunk.prepend(pending, root(attempt))
236
+ let invalid = HashSet.empty<string>()
237
+ while (Chunk.isNonEmpty(pending)) {
238
+ const node = Chunk.headNonEmpty(pending)
239
+ pending = Chunk.drop(pending, 1)
240
+ if (HashSet.has(invalid, node)) continue
241
+ invalid = HashSet.add(invalid, node)
242
+ for (const parent of Option.getOrElse(
243
+ HashMap.get(parents, node),
244
+ HashSet.empty<string>,
245
+ ))
246
+ pending = Chunk.prepend(pending, parent)
247
+ }
248
+ return invalid
209
249
  }
250
+ const prepare = (
251
+ attempts: ReadonlyArray<Attempt> = [],
252
+ invalid = HashSet.empty<string>(),
253
+ ): Generation => {
254
+ const id = ++revision
255
+ const seed = Array.reduce(attempts, 0, (id, attempt) =>
256
+ Math.max(id, attempt.generation.id),
257
+ )
258
+ let view = Option.getOrElse(
259
+ HashMap.get(views, seed),
260
+ HashMap.empty<string, string>,
261
+ )
262
+ const used = usedBy(attempts)
263
+ // Retain HAMT structure between views. Default routes need no stored entry;
264
+ // only modules reused from older views need an explicit override.
265
+ for (const [url, node] of view)
266
+ if (!HashSet.has(used, node) || HashSet.has(invalid, node))
267
+ view = HashMap.remove(view, url)
268
+ for (const node of used) {
269
+ if (HashSet.has(invalid, node)) continue
270
+ const url = clean(node)
271
+ const previous = HashMap.get(view, url)
272
+ if (Option.isNone(previous) || version(previous.value)! < version(node)!)
273
+ view = HashMap.set(view, url, node)
274
+ }
210
275
 
276
+ views = HashMap.set(views, id, view)
277
+ return { id }
278
+ }
211
279
  const hooks = registerHooks({
212
280
  resolve(specifier, context, nextResolve) {
281
+ const parentNode = context.parentURL
213
282
  const request = decodeRequest(specifier)
214
283
  const entry = Option.isSome(request)
215
284
  const id = Option.match(request, {
216
285
  onNone: () => version(context.parentURL),
217
- onSome: ([, id]) => id,
286
+ onSome: ([, id]) => Number(id),
218
287
  })
219
- if (id === null) return nextResolve(specifier, context)
220
- const resolve = () =>
221
- nextResolve(
222
- Option.match(request, {
223
- onNone: () => specifier,
224
- onSome: ([, , , target]) => target,
225
- }),
226
- {
227
- ...context,
228
- parentURL: entry ? base : clean(context.parentURL!),
229
- },
230
- )
288
+ if (id === undefined) return nextResolve(specifier, context)
289
+ const target = Option.match(request, {
290
+ onNone: () => specifier,
291
+ onSome: ([, , , target]) => target,
292
+ })
293
+ const parent = entry ? base : clean(context.parentURL!)
294
+ const lexical = Option.getOrUndefined(fileRequest(target, parent))
231
295
  const result = (() => {
232
296
  try {
233
- return resolve()
297
+ return nextResolve(target, { ...context, parentURL: parent })
234
298
  } catch (cause) {
235
- // Node reports the missing file even though resolution did not complete.
236
299
  if (
237
300
  Predicate.hasProperty(cause, "url") &&
238
301
  typeof cause.url === "string" &&
239
302
  String.startsWith("file:")(cause.url)
240
303
  ) {
241
304
  if (entry) resolved = cause.url
242
- else if (context.parentURL !== undefined && include(cause.url))
243
- link(
244
- id,
245
- clean(context.parentURL),
246
- cause.url,
247
- Option.getOrUndefined(
248
- fileRequest(specifier, clean(context.parentURL)),
249
- ),
250
- )
305
+ else if (include(cause.url))
306
+ link(parentNode!, route(cause.url, id), lexical)
251
307
  }
252
308
  throw cause
253
309
  }
254
310
  })()
255
311
  if (entry) resolved = result.url
256
- // Acquiring node:module permits require calls that Node can satisfy
257
- // without hooks. Record this capability before any cache can hide them.
258
- if (
259
- !entry &&
260
- result.url === "node:module" &&
261
- context.parentURL !== undefined
262
- ) {
263
- link(id, clean(context.parentURL), result.url)
264
- requireRestart(result.url, id)
312
+ if (!entry && result.url === "node:module") {
313
+ const node = route(result.url, id)
314
+ link(parentNode!, node)
315
+ requireRestart(node)
265
316
  }
266
317
  if (
267
318
  !String.startsWith("file:")(result.url) ||
268
319
  (!entry && !include(result.url))
269
320
  )
270
321
  return result
271
- if (!entry && context.parentURL !== undefined)
272
- link(
273
- id,
274
- clean(context.parentURL),
275
- result.url,
276
- Option.getOrUndefined(
277
- fileRequest(specifier, clean(context.parentURL)),
278
- ),
279
- )
322
+ const node = route(result.url, id)
323
+ if (!entry) link(parentNode!, node, lexical)
280
324
  const required = Array.contains(context.conditions, "require")
281
- if (required) requireRestart(result.url, id)
282
- else record(result.url, id, result.format)
283
- if (required || unsupported(result.url, result.format)) return result
284
- const url = new URL(result.url)
285
- url.searchParams.set(key, id)
286
- return { ...result, url: url.href }
325
+ if (required) requireRestart(node)
326
+ else record(node, result.format)
327
+ return required || unsupported(result.url, result.format)
328
+ ? result
329
+ : { ...result, url: node }
287
330
  },
288
331
  load(url, context, nextLoad) {
289
332
  const id = version(url)
290
- if (id === null) return nextLoad(url, context)
333
+ if (id === undefined) return nextLoad(url, context)
334
+ instantiatedViews = HashSet.add(instantiatedViews, id)
291
335
  const original = clean(url)
292
- record(original, id, context.format)
336
+ record(url, context.format)
293
337
  const result = nextLoad(original, context)
294
338
  const source = result.source
295
339
  if (source !== undefined && source !== null) {
@@ -303,103 +347,103 @@ export const modules = (base: string, include: (url: string) => boolean) => {
303
347
  source.byteLength,
304
348
  )
305
349
  : new Uint8Array(source)
306
- sources = HashMap.set(
307
- sources,
308
- id,
309
- HashMap.set(
310
- Option.getOrElse(
311
- HashMap.get(sources, id),
312
- HashMap.empty<string, string>,
313
- ),
314
- original,
315
- Base64.encode(bytes),
316
- ),
317
- )
350
+ if (importing !== undefined || HashSet.has(retained, url))
351
+ sources = HashMap.set(sources, url, digest(bytes))
318
352
  }
319
- record(original, id, result.format)
353
+ record(url, result.format)
320
354
  return result
321
355
  },
322
356
  })
357
+ const files = (attempts: ReadonlyArray<Attempt>) => {
358
+ let files = HashMap.empty<string, FileVersion>()
359
+ const put = (url: string, node: string, resolution?: string) => {
360
+ if (!String.startsWith("file:")(url) || HashSet.has(dependencies, node))
361
+ return
362
+ const generation = version(node)!
363
+ const previous = HashMap.get(files, url)
364
+ if (
365
+ Option.exists(
366
+ previous,
367
+ (previous) =>
368
+ previous.generation > generation ||
369
+ (previous.generation === generation &&
370
+ previous.resolution !== undefined),
371
+ )
372
+ )
373
+ return
374
+ files = HashMap.set(files, url, {
375
+ generation,
376
+ content: Option.getOrUndefined(HashMap.get(sources, node)),
377
+ ...(resolution === undefined ? {} : { resolution }),
378
+ })
379
+ }
380
+ for (const node of usedBy(attempts)) put(clean(node), node)
381
+ for (const attempt of resolutions(attempts))
382
+ if (attempt.request !== undefined)
383
+ put(
384
+ attempt.request,
385
+ root(attempt),
386
+ pathToFileURL(fileURLToPath(attempt.url)).href,
387
+ )
388
+ return files
389
+ }
323
390
  return {
324
391
  prepare,
392
+ files,
393
+ resolutions,
394
+ invalidated,
395
+ affected: (attempt: Attempt, invalid: HashSet.HashSet<string>) =>
396
+ HashSet.has(invalid, root(attempt)),
325
397
  fileRequest: (specifier: string) => fileRequest(specifier, base),
326
- resolutions: (attempts: ReadonlyArray<Attempt>) =>
327
- Array.flatMap(attempts, resolutionsFor),
328
- files: (attempts: ReadonlyArray<Attempt>) =>
329
- Array.reduce(
330
- attempts,
331
- HashMap.empty<string, FileVersion>(),
332
- (files, { generation, url, request }) => {
333
- const contents = Option.getOrElse(
334
- HashMap.get(sources, String.String(generation.id)),
335
- HashMap.empty<string, string>,
336
- )
337
- const unversioned = Option.getOrElse(
338
- HashMap.get(dependencies, String.String(generation.id)),
339
- HashSet.empty<string>,
340
- )
341
- const tracked = Array.reduce(
342
- Array.fromIterable(reachable(String.String(generation.id), [url])),
343
- files,
344
- (files, url) => {
345
- if (
346
- !String.startsWith("file:")(url) ||
347
- HashSet.has(unversioned, url)
348
- )
349
- return files
350
- // A retained failed lease must not override a newer import
351
- // of the same file just because its owner is visited last.
352
- if (
353
- Option.exists(
354
- HashMap.get(files, url),
355
- (current) =>
356
- current.generation > generation.id ||
357
- (current.generation === generation.id &&
358
- current.resolution !== undefined),
359
- )
360
- )
361
- return files
362
- return HashMap.set(files, url, {
363
- generation: generation.id,
364
- content: Option.getOrUndefined(HashMap.get(contents, url)),
365
- })
366
- },
367
- )
368
- return Array.reduce(
369
- resolutionsFor({
370
- generation,
371
- url,
372
- ...(request === undefined ? {} : { request }),
373
- }),
374
- tracked,
375
- (files, edge) => {
376
- if (
377
- edge.request === undefined ||
378
- HashSet.has(unversioned, edge.url) ||
379
- Option.exists(
380
- HashMap.get(files, edge.request),
381
- (file) => file.generation > generation.id,
382
- )
383
- )
384
- return files
385
- return HashMap.set(files, edge.request, {
386
- generation: generation.id,
387
- content: Option.getOrUndefined(HashMap.get(contents, edge.url)),
388
- resolution: pathToFileURL(fileURLToPath(edge.url)).href,
389
- })
390
- },
391
- )
392
- },
393
- ),
394
398
  unsupported: (generation: Generation, roots?: ReadonlyArray<string>) => {
395
- const found = Option.getOrElse(
396
- HashMap.get(dependencies, String.String(generation.id)),
397
- HashSet.empty<string>,
399
+ const used =
400
+ roots === undefined
401
+ ? undefined
402
+ : usedBy(Array.map(roots, (url) => ({ url, generation })))
403
+ return HashSet.fromIterable(
404
+ Array.map(
405
+ Array.filter(Array.fromIterable(dependencies), (node) =>
406
+ used === undefined
407
+ ? version(node) === generation.id
408
+ : HashSet.has(used, node),
409
+ ),
410
+ clean,
411
+ ),
398
412
  )
399
- if (roots === undefined) return found
400
- const used = reachable(String.String(generation.id), roots)
401
- return HashSet.filter(found, (url) => HashSet.has(used, url))
402
413
  },
414
+ collect: (attempts: ReadonlyArray<Attempt>) => {
415
+ const used = usedBy(attempts)
416
+ const ownedViews = HashSet.fromIterable(
417
+ Array.map(attempts, (attempt) => attempt.generation.id),
418
+ )
419
+ views = HashMap.filter(
420
+ views,
421
+ (_, id) =>
422
+ id === revision ||
423
+ HashSet.has(instantiatedViews, id) ||
424
+ HashSet.has(ownedViews, id),
425
+ )
426
+ // Old code can reach any default route in its instantiated view, even
427
+ // after every lease on that target closed. Preserve its complete graph,
428
+ // source baseline and native capabilities until the session closes.
429
+ const keep = (node: string) =>
430
+ HashSet.has(used, node) ||
431
+ HashSet.has(instantiatedViews, version(node)!)
432
+ retained = used
433
+ sources = HashMap.filter(sources, (_, node) => keep(node))
434
+ edges = HashMap.filter(edges, (_, node) => keep(node))
435
+ dependencies = HashSet.filter(dependencies, keep)
436
+ reachCache = HashMap.filter(reachCache, (_, node) =>
437
+ HashSet.has(used, node),
438
+ )
439
+ },
440
+ // Internal counters support deterministic retention tests and external benchmarks.
441
+ counts: () => ({
442
+ sources: HashMap.size(sources),
443
+ edges: HashMap.size(edges),
444
+ dependencies: HashSet.size(dependencies),
445
+ views: HashMap.size(views),
446
+ }),
403
447
  start: (generation: Generation, reload = false) => {
404
448
  importing = { generation, reload }
405
449
  },
@@ -411,6 +455,15 @@ export const modules = (base: string, include: (url: string) => boolean) => {
411
455
  return `${prefix}${importing!.generation.id}:${specifier}`
412
456
  },
413
457
  resolved: () => Option.fromUndefinedOr(resolved),
414
- close: () => hooks.deregister(),
458
+ close: () => {
459
+ hooks.deregister()
460
+ views = HashMap.empty()
461
+ sources = HashMap.empty()
462
+ edges = HashMap.empty()
463
+ dependencies = HashSet.empty()
464
+ reachCache = HashMap.empty()
465
+ retained = HashSet.empty()
466
+ instantiatedViews = HashSet.empty()
467
+ },
415
468
  }
416
469
  }