@graftland/hmr 0.3.0 → 0.3.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.
package/src/index.ts CHANGED
@@ -110,10 +110,10 @@ export interface Hmr {
110
110
  * Installs Node module hooks until the enclosing scope closes. Module evaluation
111
111
  * must be side-effect free. Versioned ESM cache entries are not reclaimed.
112
112
  */
113
- export const make = (
113
+ export const make: (
114
114
  options: Options,
115
- ): Effect.Effect<Hmr, never, Graft.Graft | Scope.Scope> =>
116
- Effect.gen(function* () {
115
+ ) => Effect.Effect<Hmr, never, Graft.Graft | Scope.Scope> = Effect.fnUntraced(
116
+ function* (options: Options) {
117
117
  const graft = yield* Graft.Graft
118
118
  const semaphore = yield* Semaphore.make(1)
119
119
  // A lease owns both the running graph and its latest uncommitted attempt.
@@ -130,30 +130,30 @@ export const make = (
130
130
  const session = yield* Effect.scope
131
131
  // Transfer the complete Exit before restoring interruption. Effect may replace
132
132
  // a failed masked operation with pending interruption at that boundary.
133
- const preserveExit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
134
- Effect.gen(function* () {
135
- const completed = yield* Deferred.make<Exit.Exit<A, E>>()
136
- return yield* effect.pipe(
137
- Effect.exit,
138
- Effect.tap((exit) => Deferred.succeed(completed, exit)),
139
- Effect.uninterruptible,
140
- Effect.flatten,
141
- Effect.onExit((exit) =>
142
- Effect.gen(function* () {
143
- if (Exit.isSuccess(exit) || !Cause.hasInterrupts(exit.cause))
144
- return
145
- const saved = yield* Deferred.poll(completed)
146
- if (Option.isNone(saved)) return
147
- const original = yield* saved.value
148
- if (Exit.isFailure(original))
149
- yield* Effect.logError(
150
- "HMR operation failed during cancellation",
151
- original.cause,
152
- )
153
- }),
154
- ),
155
- )
156
- })
133
+ const preserveExit = Effect.fnUntraced(function* <A, E, R>(
134
+ effect: Effect.Effect<A, E, R>,
135
+ ): Effect.fn.Return<A, E, R> {
136
+ const completed = yield* Deferred.make<Exit.Exit<A, E>>()
137
+ return yield* effect.pipe(
138
+ Effect.exit,
139
+ Effect.tap((exit) => Deferred.succeed(completed, exit)),
140
+ Effect.uninterruptible,
141
+ Effect.flatten,
142
+ Effect.onExit(
143
+ Effect.fnUntraced(function* (exit: Exit.Exit<A, E>) {
144
+ if (Exit.isSuccess(exit) || !Cause.hasInterrupts(exit.cause)) return
145
+ const saved = yield* Deferred.poll(completed)
146
+ if (Option.isNone(saved)) return
147
+ const original = yield* saved.value
148
+ if (Exit.isFailure(original))
149
+ yield* Effect.logError(
150
+ "HMR operation failed during cancellation",
151
+ original.cause,
152
+ )
153
+ }),
154
+ ),
155
+ )
156
+ })
157
157
  // Native imports cannot be cancelled: keep the lock until their promises settle.
158
158
  const serial = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
159
159
  effect.pipe(preserveExit, semaphore.withPermits(1))
@@ -310,7 +310,11 @@ export const make = (
310
310
  const load = Effect.fnUntraced(function* (specifier: string) {
311
311
  const namespace = yield* Effect.tryPromise({
312
312
  try: () => import(/* @vite-ignore */ hooks.request(specifier)),
313
- catch: (cause) => HmrError.make({ stage: "import", specifier, cause }),
313
+ // The module hooks throw RestartRequired through import(); keep its tag.
314
+ catch: (cause) =>
315
+ Schema.is(RestartRequired)(cause)
316
+ ? cause
317
+ : HmrError.make({ stage: "import", specifier, cause }),
314
318
  })
315
319
  const plugin = Plugin.fromModule(namespace)
316
320
  if (Option.isNone(plugin))
@@ -334,87 +338,87 @@ export const make = (
334
338
  }
335
339
  const changed = yield* changedResolution(attempt)
336
340
  if (Option.isSome(changed))
337
- return yield* HmrError.make({
338
- stage: "import",
339
- specifier,
340
- cause: RestartRequired.make({ url: changed.value }),
341
- })
341
+ return yield* RestartRequired.make({ url: changed.value })
342
342
  return { namespace, plugin: plugin.value, url }
343
343
  })
344
+ const acquireLease = Effect.fnUntraced(
345
+ function* (specifier: string) {
346
+ yield* checkOpen
347
+ const current = yield* Ref.get(state)
348
+ hooks.start(current.generation)
349
+ // Acquire an owner for either Exit. Rethrow only after its finalizer
350
+ // is registered, so failed imports remain available for scoped retry.
351
+ const exit = yield* Effect.exit(load(specifier))
352
+ const token: symbol = Symbol()
353
+ const root = Option.orElse(hooks.resolved(), () =>
354
+ String.startsWith("file:")(specifier)
355
+ ? Option.some(specifier)
356
+ : Option.none(),
357
+ )
358
+ const attempt = Option.map(root, (url) => ({
359
+ url,
360
+ generation: current.generation,
361
+ ...Option.match(hooks.fileRequest(specifier), {
362
+ onNone: () => ({}),
363
+ onSome: (request) => ({ request }),
364
+ }),
365
+ }))
366
+ if (Option.isSome(attempt))
367
+ yield* Ref.update(
368
+ cached,
369
+ HashMap.set(attempt.value.url, attempt.value),
370
+ )
371
+ yield* Ref.update(
372
+ owners,
373
+ HashMap.set(token, {
374
+ active: Exit.isSuccess(exit) ? attempt : Option.none(),
375
+ pending: Exit.isFailure(exit) ? attempt : Option.none(),
376
+ }),
377
+ )
378
+ if (Exit.isSuccess(exit)) {
379
+ const entry = exit.value
380
+ const previous = HashMap.get(current.entries, entry.url)
381
+ const leases = Option.match(previous, {
382
+ onNone: () => HashSet.empty<symbol>(),
383
+ onSome: (entry) => entry.leases,
384
+ })
385
+ yield* Ref.set(state, {
386
+ ...current,
387
+ entries: HashMap.set(current.entries, entry.url, {
388
+ plugin: entry.plugin,
389
+ leases: HashSet.add(leases, token),
390
+ }),
391
+ })
392
+ }
393
+ return { token, exit: Exit.map(exit, (entry) => entry.namespace) }
394
+ },
395
+ Effect.ensuring(Effect.sync(hooks.discard)),
396
+ Effect.ensuring(collect),
397
+ serial,
398
+ )
399
+ const releaseLease = Effect.fnUntraced(
400
+ function* ({ token }: { readonly token: symbol }) {
401
+ yield* Ref.update(owners, HashMap.remove(token))
402
+ yield* Ref.update(state, (current) => ({
403
+ ...current,
404
+ entries: HashMap.filterMap(current.entries, (entry) => {
405
+ const leases = HashSet.remove(entry.leases, token)
406
+ return HashSet.size(leases) === 0
407
+ ? Result.fail(undefined)
408
+ : Result.succeed({ ...entry, leases })
409
+ }),
410
+ }))
411
+ },
412
+ Effect.ensuring(collect),
413
+ serial,
414
+ )
344
415
  const importWithLease = (specifier: string) =>
345
- Effect.acquireRelease(
346
- serial(
347
- Effect.gen(function* () {
348
- yield* checkOpen
349
- const current = yield* Ref.get(state)
350
- hooks.start(current.generation)
351
- // Acquire an owner for either Exit. Rethrow only after its finalizer
352
- // is registered, so failed imports remain available for scoped retry.
353
- const exit = yield* Effect.exit(load(specifier))
354
- const token: symbol = Symbol()
355
- const root = Option.orElse(hooks.resolved(), () =>
356
- String.startsWith("file:")(specifier)
357
- ? Option.some(specifier)
358
- : Option.none(),
359
- )
360
- const attempt = Option.map(root, (url) => ({
361
- url,
362
- generation: current.generation,
363
- ...Option.match(hooks.fileRequest(specifier), {
364
- onNone: () => ({}),
365
- onSome: (request) => ({ request }),
366
- }),
367
- }))
368
- if (Option.isSome(attempt))
369
- yield* Ref.update(
370
- cached,
371
- HashMap.set(attempt.value.url, attempt.value),
372
- )
373
- yield* Ref.update(
374
- owners,
375
- HashMap.set(token, {
376
- active: Exit.isSuccess(exit) ? attempt : Option.none(),
377
- pending: Exit.isFailure(exit) ? attempt : Option.none(),
378
- }),
379
- )
380
- if (Exit.isSuccess(exit)) {
381
- const entry = exit.value
382
- const previous = HashMap.get(current.entries, entry.url)
383
- const leases = Option.match(previous, {
384
- onNone: () => HashSet.empty<symbol>(),
385
- onSome: (entry) => entry.leases,
386
- })
387
- yield* Ref.set(state, {
388
- ...current,
389
- entries: HashMap.set(current.entries, entry.url, {
390
- plugin: entry.plugin,
391
- leases: HashSet.add(leases, token),
392
- }),
393
- })
394
- }
395
- return { token, exit: Exit.map(exit, (entry) => entry.namespace) }
396
- }).pipe(
397
- Effect.ensuring(Effect.sync(hooks.discard)),
398
- Effect.ensuring(collect),
399
- ),
400
- ),
401
- ({ token }) =>
402
- serial(
403
- Effect.gen(function* () {
404
- yield* Ref.update(owners, HashMap.remove(token))
405
- yield* Ref.update(state, (current) => ({
406
- ...current,
407
- entries: HashMap.filterMap(current.entries, (entry) => {
408
- const leases = HashSet.remove(entry.leases, token)
409
- return HashSet.size(leases) === 0
410
- ? Result.fail(undefined)
411
- : Result.succeed({ ...entry, leases })
412
- }),
413
- }))
414
- }).pipe(Effect.ensuring(collect)),
415
- ),
416
- ).pipe(
416
+ Effect.acquireRelease(acquireLease(specifier), releaseLease).pipe(
417
417
  Effect.flatMap(({ exit }) => exit),
418
+ // Importers cannot act on a restart; reloadChanged handles it by tag.
419
+ Effect.catchTag("RestartRequired", (cause) =>
420
+ Effect.fail(HmrError.make({ stage: "import", specifier, cause })),
421
+ ),
418
422
  preserveExit,
419
423
  )
420
424
 
@@ -465,248 +469,236 @@ export const make = (
465
469
  ),
466
470
  )
467
471
 
468
- const reloadChanged = (dirty?: HashSet.HashSet<string>) =>
469
- serial(
470
- Effect.gen(function* () {
471
- yield* checkOpen
472
- const fullRetry = yield* Ref.get(retryAll)
473
- const force = dirty === undefined || fullRetry
474
- const pendingDirty = yield* Ref.get(retryDirty)
475
- const changedFiles =
476
- dirty === undefined
477
- ? pendingDirty
478
- : HashSet.union(pendingDirty, dirty)
479
- yield* Ref.set(retryDirty, changedFiles)
480
- yield* Ref.set(retryAll, force)
481
- const changed = yield* changedResolutions
482
- if (changed.length > 0)
483
- return WatchResult.Skipped({
484
- reason: "restart-required",
485
- modules: changed,
486
- })
487
- const { entries } = yield* Ref.get(state)
488
- const unsupported = yield* unsupportedModules
489
- if (HashSet.size(unsupported) > 0)
490
- return WatchResult.Skipped({
491
- modules: Array.fromIterable(unsupported),
492
- reason: "restart-required",
493
- })
494
- const owned = yield* Ref.get(owners)
495
- const attempts = Array.flatMap(HashMap.toValues(owned), (owner) =>
496
- Array.getSomes([owner.active, owner.pending]),
497
- )
498
- const active = Array.getSomes(
499
- Array.map(HashMap.toValues(owned), (owner) => owner.active),
500
- )
501
- let selected = entries
502
- let invalid = HashSet.empty<string>()
503
- if (!force) {
504
- let affected = changedFiles
505
- // Plugin identity groups and importer closure can expand each other.
506
- // Reach a fixed point before allocating any candidate instance.
507
- while (true) {
508
- invalid = hooks.invalidated(attempts, affected)
509
- const plugins = HashSet.fromIterable(
510
- Array.filterMap(Array.fromIterable(entries), ([url, entry]) =>
511
- HashSet.has(affected, url) ||
512
- Array.some(Array.fromIterable(entry.leases), (token) =>
513
- Option.exists(HashMap.get(owned, token), (owner) =>
514
- Array.some(
515
- Array.getSomes([owner.active, owner.pending]),
516
- (attempt) => hooks.affected(attempt, invalid),
517
- ),
518
- ),
519
- )
520
- ? Result.succeed(entry.plugin)
521
- : Result.fail(undefined),
472
+ const reloadChanged = Effect.fnUntraced(function* (
473
+ dirty: HashSet.HashSet<string> | undefined,
474
+ ) {
475
+ yield* checkOpen
476
+ const fullRetry = yield* Ref.get(retryAll)
477
+ const force = dirty === undefined || fullRetry
478
+ const pendingDirty = yield* Ref.get(retryDirty)
479
+ const changedFiles =
480
+ dirty === undefined ? pendingDirty : HashSet.union(pendingDirty, dirty)
481
+ yield* Ref.set(retryDirty, changedFiles)
482
+ yield* Ref.set(retryAll, force)
483
+ const changed = yield* changedResolutions
484
+ if (changed.length > 0)
485
+ return WatchResult.Skipped({
486
+ reason: "restart-required",
487
+ modules: changed,
488
+ })
489
+ const { entries } = yield* Ref.get(state)
490
+ const unsupported = yield* unsupportedModules
491
+ if (HashSet.size(unsupported) > 0)
492
+ return WatchResult.Skipped({
493
+ modules: Array.fromIterable(unsupported),
494
+ reason: "restart-required",
495
+ })
496
+ const owned = yield* Ref.get(owners)
497
+ const attempts = Array.flatMap(HashMap.toValues(owned), (owner) =>
498
+ Array.getSomes([owner.active, owner.pending]),
499
+ )
500
+ const active = Array.getSomes(
501
+ Array.map(HashMap.toValues(owned), (owner) => owner.active),
502
+ )
503
+ let selected = entries
504
+ let invalid = HashSet.empty<string>()
505
+ if (!force) {
506
+ let affected = changedFiles
507
+ // Plugin identity groups and importer closure can expand each other.
508
+ // Reach a fixed point before allocating any candidate instance.
509
+ while (true) {
510
+ invalid = hooks.invalidated(attempts, affected)
511
+ const plugins = HashSet.fromIterable(
512
+ Array.filterMap(Array.fromIterable(entries), ([url, entry]) =>
513
+ HashSet.has(affected, url) ||
514
+ Array.some(Array.fromIterable(entry.leases), (token) =>
515
+ Option.exists(HashMap.get(owned, token), (owner) =>
516
+ Array.some(
517
+ Array.getSomes([owner.active, owner.pending]),
518
+ (attempt) => hooks.affected(attempt, invalid),
519
+ ),
522
520
  ),
523
521
  )
524
- selected = HashMap.filter(entries, (entry) =>
525
- HashSet.has(plugins, entry.plugin),
526
- )
527
- const expanded = HashSet.union(
528
- affected,
529
- HashSet.fromIterable(HashMap.keys(selected)),
530
- )
531
- if (HashSet.size(expanded) === HashSet.size(affected)) break
532
- affected = expanded
533
- }
534
- }
535
- // Advance even without entries so retries bypass cached failed imports.
536
- const candidate = force
537
- ? hooks.prepare()
538
- : hooks.prepare(active, invalid)
522
+ ? Result.succeed(entry.plugin)
523
+ : Result.fail(undefined),
524
+ ),
525
+ )
526
+ selected = HashMap.filter(entries, (entry) =>
527
+ HashSet.has(plugins, entry.plugin),
528
+ )
529
+ const expanded = HashSet.union(
530
+ affected,
531
+ HashSet.fromIterable(HashMap.keys(selected)),
532
+ )
533
+ if (HashSet.size(expanded) === HashSet.size(affected)) break
534
+ affected = expanded
535
+ }
536
+ }
537
+ // Advance even without entries so retries bypass cached failed imports.
538
+ const candidate = force ? hooks.prepare() : hooks.prepare(active, invalid)
539
539
 
540
- hooks.start(candidate, true)
541
- return yield* Effect.gen(function* () {
542
- const loaded = yield* Effect.forEach(selected, ([url, previous]) =>
543
- Ref.update(
544
- owners,
545
- HashMap.map((owner, token) =>
546
- HashSet.has(previous.leases, token)
547
- ? {
548
- ...owner,
549
- pending: Option.map(owner.active, (attempt) => ({
550
- ...attempt,
551
- url,
552
- generation: candidate,
553
- })),
554
- }
555
- : owner,
556
- ),
557
- ).pipe(
558
- Effect.andThen(load(url)),
559
- Effect.map((entry) =>
560
- Option.some({
561
- url,
562
- previous: previous.plugin,
563
- leases: previous.leases,
564
- plugin: entry.plugin,
565
- }),
566
- ),
567
- Effect.catchIf(
568
- (error) =>
569
- error.stage === "import" &&
570
- isMissingEntry(url, error.cause),
571
- () => Effect.succeedNone,
572
- ),
573
- ),
574
- )
575
- const candidates = Array.getSomes(loaded)
576
- const alternatives = Array.reduce(
577
- candidates,
578
- HashMap.empty<Plugin.Plugin, HashSet.HashSet<Plugin.Plugin>>(),
579
- (groups, { previous, plugin }) =>
580
- HashMap.set(
581
- groups,
582
- previous,
583
- HashSet.add(
584
- Option.getOrElse(
585
- HashMap.get(groups, previous),
586
- HashSet.empty<Plugin.Plugin>,
587
- ),
588
- plugin,
589
- ),
540
+ hooks.start(candidate, true)
541
+ return yield* Effect.gen(function* () {
542
+ const loaded = yield* Effect.forEach(selected, ([url, previous]) =>
543
+ Ref.update(
544
+ owners,
545
+ HashMap.map((owner, token) =>
546
+ HashSet.has(previous.leases, token)
547
+ ? {
548
+ ...owner,
549
+ pending: Option.map(owner.active, (attempt) => ({
550
+ ...attempt,
551
+ url,
552
+ generation: candidate,
553
+ })),
554
+ }
555
+ : owner,
556
+ ),
557
+ ).pipe(
558
+ Effect.andThen(load(url)),
559
+ Effect.map((entry) =>
560
+ Option.some({
561
+ url,
562
+ previous: previous.plugin,
563
+ leases: previous.leases,
564
+ plugin: entry.plugin,
565
+ }),
566
+ ),
567
+ Effect.catchIf(
568
+ (error) =>
569
+ error._tag === "HmrError" &&
570
+ error.stage === "import" &&
571
+ isMissingEntry(url, error.cause),
572
+ () => Effect.succeedNone,
573
+ ),
574
+ ),
575
+ )
576
+ const candidates = Array.getSomes(loaded)
577
+ const alternatives = Array.reduce(
578
+ candidates,
579
+ HashMap.empty<Plugin.Plugin, HashSet.HashSet<Plugin.Plugin>>(),
580
+ (groups, { previous, plugin }) =>
581
+ HashMap.set(
582
+ groups,
583
+ previous,
584
+ HashSet.add(
585
+ Option.getOrElse(
586
+ HashMap.get(groups, previous),
587
+ HashSet.empty<Plugin.Plugin>,
590
588
  ),
591
- )
592
- const ambiguous = Array.filter(candidates, ({ previous }) =>
593
- Option.exists(
594
- HashMap.get(alternatives, previous),
595
- (plugins) => HashSet.size(plugins) > 1,
589
+ plugin,
596
590
  ),
597
- )
598
- if (ambiguous.length > 0)
599
- return WatchResult.Skipped({
600
- reason: "ambiguous-exports",
601
- modules: Array.map(ambiguous, ({ url }) => url),
602
- })
603
- const replacements = HashMap.fromIterable(
604
- Array.map(
605
- candidates,
606
- ({ url, plugin, leases }) => [url, { plugin, leases }] as const,
607
- ),
608
- )
609
- // A missing entry still owns its running plugin until the last lease closes.
610
- const next = HashMap.union(entries, replacements)
591
+ ),
592
+ )
593
+ const ambiguous = Array.filter(candidates, ({ previous }) =>
594
+ Option.exists(
595
+ HashMap.get(alternatives, previous),
596
+ (plugins) => HashSet.size(plugins) > 1,
597
+ ),
598
+ )
599
+ if (ambiguous.length > 0)
600
+ return WatchResult.Skipped({
601
+ reason: "ambiguous-exports",
602
+ modules: Array.map(ambiguous, ({ url }) => url),
603
+ })
604
+ const replacements = HashMap.fromIterable(
605
+ Array.map(
606
+ candidates,
607
+ ({ url, plugin, leases }) => [url, { plugin, leases }] as const,
608
+ ),
609
+ )
610
+ // A missing entry still owns its running plugin until the last lease closes.
611
+ const next = HashMap.union(entries, replacements)
611
612
 
612
- const pairs = Array.map(
613
- candidates,
614
- ({ previous, plugin }) => [previous, plugin] as const,
615
- )
616
- const changed = yield* changedResolutions
617
- if (changed.length > 0)
618
- return WatchResult.Skipped({
619
- reason: "restart-required",
620
- modules: changed,
621
- })
622
- // A reused instance can acquire a native loading capability while the
623
- // candidate imports. Observe it without throwing into concurrent old code.
624
- const unsafe = unsupportedAttempts(
625
- Array.map(active, (attempt) =>
626
- HashMap.has(replacements, attempt.url)
627
- ? { ...attempt, generation: candidate }
628
- : attempt,
629
- ),
630
- )
631
- if (HashSet.size(unsafe) > 0)
632
- return WatchResult.Skipped({
633
- reason: "restart-required",
634
- modules: Array.fromIterable(unsafe),
635
- })
613
+ const pairs = Array.map(
614
+ candidates,
615
+ ({ previous, plugin }) => [previous, plugin] as const,
616
+ )
617
+ const changed = yield* changedResolutions
618
+ if (changed.length > 0)
619
+ return WatchResult.Skipped({
620
+ reason: "restart-required",
621
+ modules: changed,
622
+ })
623
+ // A reused instance can acquire a native loading capability while the
624
+ // candidate imports. Observe it without throwing into concurrent old code.
625
+ const unsafe = unsupportedAttempts(
626
+ Array.map(active, (attempt) =>
627
+ HashMap.has(replacements, attempt.url)
628
+ ? { ...attempt, generation: candidate }
629
+ : attempt,
630
+ ),
631
+ )
632
+ if (HashSet.size(unsafe) > 0)
633
+ return WatchResult.Skipped({
634
+ reason: "restart-required",
635
+ modules: Array.fromIterable(unsafe),
636
+ })
636
637
 
637
- const replacement = yield* Graft.replace(pairs).pipe(
638
- Effect.provideService(Graft.Graft, graft),
639
- Effect.result,
640
- )
641
- if (Result.isFailure(replacement))
642
- return WatchResult.Skipped({
643
- reason: "identity-conflict",
644
- modules: Array.filterMap(candidates, ({ url }, index) =>
645
- Array.contains(replacement.failure.indices, index)
646
- ? Result.succeed(url)
647
- : Result.fail(undefined),
648
- ),
649
- })
650
- yield* Ref.update(owners, (owners) =>
651
- Array.reduce(candidates, owners, (owners, { url, leases }) =>
652
- HashMap.map(owners, (owner, token) =>
653
- HashSet.has(leases, token)
654
- ? {
655
- active: Option.map(owner.pending, (attempt) => ({
656
- ...attempt,
657
- url,
658
- generation: candidate,
659
- })),
660
- pending: Option.none(),
661
- }
662
- : owner,
663
- ),
664
- ),
665
- )
666
- yield* Ref.set(state, { entries: next, generation: candidate })
667
- yield* Ref.update(cached, (cached) =>
668
- HashMap.union(
669
- HashMap.filter(cached, (_, url) => HashMap.has(next, url)),
670
- HashMap.map(replacements, (_, url) => ({
671
- url,
672
- generation: candidate,
673
- })),
674
- ),
675
- )
676
- yield* Ref.set(retryDirty, HashSet.empty())
677
- yield* Ref.set(retryAll, false)
678
- return WatchResult.Reloaded({
679
- entries: Array.map(candidates, (candidate) => candidate.url),
680
- })
681
- }).pipe(
682
- Effect.catchIf(
683
- (
684
- error,
685
- ): error is HmrError & { readonly cause: RestartRequired } =>
686
- Schema.is(RestartRequired)(error.cause),
687
- (error) =>
688
- Effect.succeed(
689
- WatchResult.Skipped({
690
- modules: Array.fromIterable(
691
- HashSet.add(
692
- hooks.unsupported(candidate),
693
- error.cause.url,
694
- ),
695
- ),
696
- reason: "restart-required",
697
- }),
698
- ),
638
+ const replacement = yield* Graft.replace(pairs).pipe(
639
+ Effect.provideService(Graft.Graft, graft),
640
+ Effect.result,
641
+ )
642
+ if (Result.isFailure(replacement))
643
+ return WatchResult.Skipped({
644
+ reason: "identity-conflict",
645
+ modules: Array.filterMap(candidates, ({ url }, index) =>
646
+ Array.contains(replacement.failure.indices, index)
647
+ ? Result.succeed(url)
648
+ : Result.fail(undefined),
699
649
  ),
700
- Effect.ensuring(Effect.sync(hooks.discard)),
701
- Effect.ensuring(collect),
702
- )
703
- }),
650
+ })
651
+ yield* Ref.update(owners, (owners) =>
652
+ Array.reduce(candidates, owners, (owners, { url, leases }) =>
653
+ HashMap.map(owners, (owner, token) =>
654
+ HashSet.has(leases, token)
655
+ ? {
656
+ active: Option.map(owner.pending, (attempt) => ({
657
+ ...attempt,
658
+ url,
659
+ generation: candidate,
660
+ })),
661
+ pending: Option.none(),
662
+ }
663
+ : owner,
664
+ ),
665
+ ),
666
+ )
667
+ yield* Ref.set(state, { entries: next, generation: candidate })
668
+ yield* Ref.update(cached, (cached) =>
669
+ HashMap.union(
670
+ HashMap.filter(cached, (_, url) => HashMap.has(next, url)),
671
+ HashMap.map(replacements, (_, url) => ({
672
+ url,
673
+ generation: candidate,
674
+ })),
675
+ ),
676
+ )
677
+ yield* Ref.set(retryDirty, HashSet.empty())
678
+ yield* Ref.set(retryAll, false)
679
+ return WatchResult.Reloaded({
680
+ entries: Array.map(candidates, (candidate) => candidate.url),
681
+ })
682
+ }).pipe(
683
+ Effect.catchTag("RestartRequired", (error) =>
684
+ Effect.succeed(
685
+ WatchResult.Skipped({
686
+ modules: Array.fromIterable(
687
+ HashSet.add(hooks.unsupported(candidate), error.url),
688
+ ),
689
+ reason: "restart-required",
690
+ }),
691
+ ),
692
+ ),
693
+ Effect.ensuring(Effect.sync(hooks.discard)),
694
+ Effect.ensuring(collect),
704
695
  )
696
+ }, serial)
705
697
  return {
706
698
  importer,
707
699
  importerScoped,
708
700
  importModule,
709
- reload: reloadChanged(),
701
+ reload: reloadChanged(undefined),
710
702
  watch: (paths, watchOptions) =>
711
703
  paths.length === 0
712
704
  ? Stream.empty
@@ -765,4 +757,5 @@ export const make = (
765
757
  ),
766
758
  ),
767
759
  }
768
- })
760
+ },
761
+ )