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

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,6 +73,7 @@ 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}:`
76
+ const rawPrefix = `${key}:raw:`
74
77
  /** What `request` produces: the generation that imports a specifier. */
75
78
  const decodeRequest = Schema.decodeUnknownOption(
76
79
  Schema.TemplateLiteralParser([
@@ -83,213 +86,261 @@ export const modules = (base: string, include: (url: string) => boolean) => {
83
86
  let revision = 0
84
87
  let importing:
85
88
  { 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
- }),
89
+ let resolved: string | undefined
90
+ // Resolution views contain only routing identities. Old code can execute after
91
+ // replacement (including teardown), so these persistent maps live until close.
92
+ // Cached instances also retain their facts: Node does not replay static imports.
93
+ // Leases restrict observation, not the lifetime of old routing targets.
94
+ let views = HashMap.empty<number, HashMap.HashMap<string, string>>()
95
+ let edges = HashMap.empty<string, HashMap.HashMap<string, Dependency>>()
96
+ let sources = HashMap.empty<string, string>()
97
+ let dependencies = HashSet.empty<string>()
98
+ let reachCache = HashMap.empty<string, HashSet.HashSet<string>>()
99
+ let retained = HashSet.empty<string>()
100
+ let instantiatedViews = HashSet.empty<number>()
101
+ const clean = (url: string): string => {
102
+ const value = new URL(url)
103
+ value.searchParams.delete(key)
104
+ return value.href
105
+ }
106
+ const version = (url: string | undefined): number | undefined => {
107
+ if (url === undefined) return undefined
108
+ const value = new URL(url).searchParams.get(key)
109
+ return value === null ? undefined : Number(value)
110
+ }
111
+ const tag = (url: string, id: number) => {
112
+ const value = new URL(url)
113
+ value.searchParams.set(key, String.String(id))
114
+ return value.href
115
+ }
116
+ const route = (url: string, id: number): string =>
117
+ Option.getOrElse(
118
+ HashMap.get(
119
+ Option.getOrElse(HashMap.get(views, id), HashMap.empty<string, string>),
120
+ url,
118
121
  ),
122
+ () => tag(url, id),
119
123
  )
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>>,
124
+
125
+ const root = (attempt: Attempt) => route(attempt.url, attempt.generation.id)
126
+ const children = (node: string) =>
127
+ Option.getOrElse(
128
+ HashMap.get(edges, node),
129
+ HashMap.empty<string, Dependency>,
125
130
  )
131
+ const reachable = (node: string) => {
132
+ const cached = HashMap.get(reachCache, node)
133
+ if (Option.isSome(cached)) return cached.value
126
134
  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
- )
135
+ let pending: Chunk.Chunk<string> = Chunk.of(node)
136
+ while (Chunk.isNonEmpty(pending)) {
137
+ const current = Chunk.headNonEmpty(pending)
138
+ pending = Chunk.drop(pending, 1)
139
+ if (HashSet.has(seen, current)) continue
140
+ seen = HashSet.add(seen, current)
141
+ for (const edge of HashMap.values(children(current)))
142
+ pending = Chunk.prepend(pending, edge.node)
145
143
  }
144
+ reachCache = HashMap.set(reachCache, node, seen)
146
145
  return seen
147
146
  }
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
- ]
147
+ const usedBy = (attempts: ReadonlyArray<Attempt>) => {
148
+ const roots = HashSet.fromIterable(Array.map(attempts, root))
149
+ let used = HashSet.empty<string>()
150
+ for (const node of roots) used = HashSet.union(used, reachable(node))
151
+ return used
166
152
  }
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
153
+ const link = (parent: string, node: string, request?: string) => {
154
+ if (
155
+ importing === undefined &&
156
+ !HashSet.has(retained, parent) &&
157
+ !HashSet.has(instantiatedViews, version(parent)!)
158
+ )
159
+ return
160
+ retained = HashSet.add(retained, node)
161
+ const url = clean(node)
162
+ const edge = {
163
+ node,
164
+ url,
165
+ generation: { id: version(node)! },
166
+ ...(request === undefined ? {} : { request }),
167
+ }
168
+ const previous = HashMap.get(children(parent), request ?? url)
169
+ if (
170
+ Option.exists(
171
+ previous,
172
+ (previous) => previous.node === node && previous.request === request,
173
+ )
174
+ )
175
+ return
176
+ edges = HashMap.set(
177
+ edges,
178
+ parent,
179
+ HashMap.set(children(parent), request ?? url, edge),
180
+ )
181
+ reachCache = HashMap.empty()
178
182
  }
179
- const version = (url: string | undefined): string | null =>
180
- url === undefined ? null : new URL(url).searchParams.get(key)
183
+ const resolutions = (
184
+ attempts: ReadonlyArray<Attempt>,
185
+ ): ReadonlyArray<Attempt> => [
186
+ ...Array.filter(attempts, (attempt) => attempt.request !== undefined),
187
+ ...Array.flatMap(Array.fromIterable(usedBy(attempts)), (node) =>
188
+ Array.filter(
189
+ HashMap.toValues(children(node)),
190
+ (edge) => edge.request !== undefined,
191
+ ),
192
+ ),
193
+ ]
181
194
  const unsupported = (url: string, format: string | null | undefined) =>
182
195
  format === "commonjs" ||
183
196
  format === "commonjs-typescript" ||
184
197
  format === "addon" ||
185
198
  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 })
199
+ const requireRestart = (node: string) => {
200
+ if (importing !== undefined || HashSet.has(retained, node))
201
+ dependencies = HashSet.add(dependencies, node)
202
+ if (importing?.reload === true && importing.generation.id === version(node))
203
+ throw RestartRequired.make({ url: clean(node) })
201
204
  }
202
-
203
- const record = (
204
- url: string,
205
- id: string,
206
- format: string | null | undefined,
205
+ const record = (node: string, format: string | null | undefined) => {
206
+ if (unsupported(clean(node), format)) requireRestart(node)
207
+ }
208
+ // One reverse traversal closes cycles and all importers of every dirty instance.
209
+ const invalidated = (
210
+ attempts: ReadonlyArray<Attempt>,
211
+ dirty: HashSet.HashSet<string>,
207
212
  ) => {
208
- if (unsupported(url, format)) requireRestart(url, id)
213
+ const used = usedBy(attempts)
214
+ let parents = HashMap.empty<string, HashSet.HashSet<string>>()
215
+ let pending = Chunk.empty<string>()
216
+ for (const node of used) {
217
+ if (HashSet.has(dirty, clean(node)))
218
+ pending = Chunk.prepend(pending, node)
219
+ for (const edge of HashMap.values(children(node))) {
220
+ parents = HashMap.set(
221
+ parents,
222
+ edge.node,
223
+ HashSet.add(
224
+ Option.getOrElse(
225
+ HashMap.get(parents, edge.node),
226
+ HashSet.empty<string>,
227
+ ),
228
+ node,
229
+ ),
230
+ )
231
+ if (edge.request !== undefined && HashSet.has(dirty, edge.request))
232
+ pending = Chunk.prepend(pending, edge.node)
233
+ }
234
+ }
235
+ for (const attempt of attempts)
236
+ if (attempt.request !== undefined && HashSet.has(dirty, attempt.request))
237
+ pending = Chunk.prepend(pending, root(attempt))
238
+ let invalid = HashSet.empty<string>()
239
+ while (Chunk.isNonEmpty(pending)) {
240
+ const node = Chunk.headNonEmpty(pending)
241
+ pending = Chunk.drop(pending, 1)
242
+ if (HashSet.has(invalid, node)) continue
243
+ invalid = HashSet.add(invalid, node)
244
+ for (const parent of Option.getOrElse(
245
+ HashMap.get(parents, node),
246
+ HashSet.empty<string>,
247
+ ))
248
+ pending = Chunk.prepend(pending, parent)
249
+ }
250
+ return invalid
209
251
  }
252
+ const prepare = (
253
+ attempts: ReadonlyArray<Attempt> = [],
254
+ invalid = HashSet.empty<string>(),
255
+ ): Generation => {
256
+ const id = ++revision
257
+ const seed = Array.reduce(attempts, 0, (id, attempt) =>
258
+ Math.max(id, attempt.generation.id),
259
+ )
260
+ let view = Option.getOrElse(
261
+ HashMap.get(views, seed),
262
+ HashMap.empty<string, string>,
263
+ )
264
+ const used = usedBy(attempts)
265
+ // Retain HAMT structure between views. Default routes need no stored entry;
266
+ // only modules reused from older views need an explicit override.
267
+ for (const [url, node] of view)
268
+ if (!HashSet.has(used, node) || HashSet.has(invalid, node))
269
+ view = HashMap.remove(view, url)
270
+ for (const node of used) {
271
+ if (HashSet.has(invalid, node)) continue
272
+ const url = clean(node)
273
+ const previous = HashMap.get(view, url)
274
+ if (Option.isNone(previous) || version(previous.value)! < version(node)!)
275
+ view = HashMap.set(view, url, node)
276
+ }
210
277
 
278
+ views = HashMap.set(views, id, view)
279
+ return { id }
280
+ }
211
281
  const hooks = registerHooks({
212
282
  resolve(specifier, context, nextResolve) {
283
+ if (String.startsWith(rawPrefix)(specifier))
284
+ return nextResolve(String.slice(rawPrefix.length)(specifier), {
285
+ ...context,
286
+ parentURL: base,
287
+ })
288
+ const parentNode = context.parentURL
213
289
  const request = decodeRequest(specifier)
214
290
  const entry = Option.isSome(request)
215
291
  const id = Option.match(request, {
216
292
  onNone: () => version(context.parentURL),
217
- onSome: ([, id]) => id,
293
+ onSome: ([, id]) => Number(id),
218
294
  })
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
- )
295
+ if (id === undefined) return nextResolve(specifier, context)
296
+ const target = Option.match(request, {
297
+ onNone: () => specifier,
298
+ onSome: ([, , , target]) => target,
299
+ })
300
+ const parent = entry ? base : clean(context.parentURL!)
301
+ const lexical = Option.getOrUndefined(fileRequest(target, parent))
231
302
  const result = (() => {
232
303
  try {
233
- return resolve()
304
+ return nextResolve(target, { ...context, parentURL: parent })
234
305
  } catch (cause) {
235
- // Node reports the missing file even though resolution did not complete.
236
306
  if (
237
307
  Predicate.hasProperty(cause, "url") &&
238
308
  typeof cause.url === "string" &&
239
309
  String.startsWith("file:")(cause.url)
240
310
  ) {
241
311
  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
- )
312
+ else if (include(cause.url))
313
+ link(parentNode!, route(cause.url, id), lexical)
251
314
  }
252
315
  throw cause
253
316
  }
254
317
  })()
255
318
  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)
319
+ if (!entry && result.url === "node:module") {
320
+ const node = route(result.url, id)
321
+ link(parentNode!, node)
322
+ requireRestart(node)
265
323
  }
266
324
  if (
267
325
  !String.startsWith("file:")(result.url) ||
268
326
  (!entry && !include(result.url))
269
327
  )
270
328
  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
- )
329
+ const node = route(result.url, id)
330
+ if (!entry) link(parentNode!, node, lexical)
280
331
  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 }
332
+ if (required) requireRestart(node)
333
+ else record(node, result.format)
334
+ return required || unsupported(result.url, result.format)
335
+ ? result
336
+ : { ...result, url: node }
287
337
  },
288
338
  load(url, context, nextLoad) {
289
339
  const id = version(url)
290
- if (id === null) return nextLoad(url, context)
340
+ if (id === undefined) return nextLoad(url, context)
341
+ instantiatedViews = HashSet.add(instantiatedViews, id)
291
342
  const original = clean(url)
292
- record(original, id, context.format)
343
+ record(url, context.format)
293
344
  const result = nextLoad(original, context)
294
345
  const source = result.source
295
346
  if (source !== undefined && source !== null) {
@@ -303,103 +354,103 @@ export const modules = (base: string, include: (url: string) => boolean) => {
303
354
  source.byteLength,
304
355
  )
305
356
  : 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
- )
357
+ if (importing !== undefined || HashSet.has(retained, url))
358
+ sources = HashMap.set(sources, url, digest(bytes))
318
359
  }
319
- record(original, id, result.format)
360
+ record(url, result.format)
320
361
  return result
321
362
  },
322
363
  })
364
+ const files = (attempts: ReadonlyArray<Attempt>) => {
365
+ let files = HashMap.empty<string, FileVersion>()
366
+ const put = (url: string, node: string, resolution?: string) => {
367
+ if (!String.startsWith("file:")(url) || HashSet.has(dependencies, node))
368
+ return
369
+ const generation = version(node)!
370
+ const previous = HashMap.get(files, url)
371
+ if (
372
+ Option.exists(
373
+ previous,
374
+ (previous) =>
375
+ previous.generation > generation ||
376
+ (previous.generation === generation &&
377
+ previous.resolution !== undefined),
378
+ )
379
+ )
380
+ return
381
+ files = HashMap.set(files, url, {
382
+ generation,
383
+ content: Option.getOrUndefined(HashMap.get(sources, node)),
384
+ ...(resolution === undefined ? {} : { resolution }),
385
+ })
386
+ }
387
+ for (const node of usedBy(attempts)) put(clean(node), node)
388
+ for (const attempt of resolutions(attempts))
389
+ if (attempt.request !== undefined)
390
+ put(
391
+ attempt.request,
392
+ root(attempt),
393
+ pathToFileURL(fileURLToPath(attempt.url)).href,
394
+ )
395
+ return files
396
+ }
323
397
  return {
324
398
  prepare,
399
+ files,
400
+ resolutions,
401
+ invalidated,
402
+ affected: (attempt: Attempt, invalid: HashSet.HashSet<string>) =>
403
+ HashSet.has(invalid, root(attempt)),
325
404
  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
405
  unsupported: (generation: Generation, roots?: ReadonlyArray<string>) => {
395
- const found = Option.getOrElse(
396
- HashMap.get(dependencies, String.String(generation.id)),
397
- HashSet.empty<string>,
406
+ const used =
407
+ roots === undefined
408
+ ? undefined
409
+ : usedBy(Array.map(roots, (url) => ({ url, generation })))
410
+ return HashSet.fromIterable(
411
+ Array.map(
412
+ Array.filter(Array.fromIterable(dependencies), (node) =>
413
+ used === undefined
414
+ ? version(node) === generation.id
415
+ : HashSet.has(used, node),
416
+ ),
417
+ clean,
418
+ ),
398
419
  )
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
420
  },
421
+ collect: (attempts: ReadonlyArray<Attempt>) => {
422
+ const used = usedBy(attempts)
423
+ const ownedViews = HashSet.fromIterable(
424
+ Array.map(attempts, (attempt) => attempt.generation.id),
425
+ )
426
+ views = HashMap.filter(
427
+ views,
428
+ (_, id) =>
429
+ id === revision ||
430
+ HashSet.has(instantiatedViews, id) ||
431
+ HashSet.has(ownedViews, id),
432
+ )
433
+ // Old code can reach any default route in its instantiated view, even
434
+ // after every lease on that target closed. Preserve its complete graph,
435
+ // source baseline and native capabilities until the session closes.
436
+ const keep = (node: string) =>
437
+ HashSet.has(used, node) ||
438
+ HashSet.has(instantiatedViews, version(node)!)
439
+ retained = used
440
+ sources = HashMap.filter(sources, (_, node) => keep(node))
441
+ edges = HashMap.filter(edges, (_, node) => keep(node))
442
+ dependencies = HashSet.filter(dependencies, keep)
443
+ reachCache = HashMap.filter(reachCache, (_, node) =>
444
+ HashSet.has(used, node),
445
+ )
446
+ },
447
+ // Internal counters support deterministic retention tests and external benchmarks.
448
+ counts: () => ({
449
+ sources: HashMap.size(sources),
450
+ edges: HashMap.size(edges),
451
+ dependencies: HashSet.size(dependencies),
452
+ views: HashMap.size(views),
453
+ }),
403
454
  start: (generation: Generation, reload = false) => {
404
455
  importing = { generation, reload }
405
456
  },
@@ -410,7 +461,17 @@ export const modules = (base: string, include: (url: string) => boolean) => {
410
461
  resolved = undefined
411
462
  return `${prefix}${importing!.generation.id}:${specifier}`
412
463
  },
464
+ rawRequest: (specifier: string) => `${rawPrefix}${specifier}`,
413
465
  resolved: () => Option.fromUndefinedOr(resolved),
414
- close: () => hooks.deregister(),
466
+ close: () => {
467
+ hooks.deregister()
468
+ views = HashMap.empty()
469
+ sources = HashMap.empty()
470
+ edges = HashMap.empty()
471
+ dependencies = HashSet.empty()
472
+ reachCache = HashMap.empty()
473
+ retained = HashSet.empty()
474
+ instantiatedViews = HashSet.empty()
475
+ },
415
476
  }
416
477
  }