@linxin666/dsh-usage 0.4.2 → 0.4.3

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.
@@ -0,0 +1,142 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
3
+ /**
4
+ * Family settings form bound to the profile entry id a Host actually serves.
5
+ *
6
+ * Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
7
+ * a family card knows only its own settings namespace. The family binder
8
+ * (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
9
+ * the other and is the preferred transport; this module is the fallback for a
10
+ * page that serves no binder — the group plugin is not installed, or its client
11
+ * half has not applied yet (the aggregate mounts its children in order, and a
12
+ * child still waiting on its own injects publishes nothing).
13
+ *
14
+ * The shared describe mirror is the only local evidence of which row id this
15
+ * profile serves, but it answers asynchronously: at plugin activation it usually
16
+ * holds nothing yet. A one-shot guess therefore cannot be right for every
17
+ * deployment, and the form is bound once per session:
18
+ *
19
+ * - binding the bare namespace made every save answer `No configurable plugin
20
+ * entry "<namespace>"` on an aggregate install (the namespace is not an entry
21
+ * id there), which the card reported as "the deployment did not accept these
22
+ * values";
23
+ * - binding the aggregate row id would break a standalone install, whose row id
24
+ * is the namespace or the package's own `ui-*` row.
25
+ *
26
+ * So the fallback starts on the most likely row and REBINDS the moment the
27
+ * mirror names one of this package's rows. Until the mirror answers, the form
28
+ * reports the binding it holds (usually `loading`), which the card renders as an
29
+ * unavailable form rather than a doomed one.
30
+ */
31
+ import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
32
+
33
+ /** Options of {@link createServedEntryForm}. */
34
+ export interface ServedEntryFormOptions<T> {
35
+ /** The shared configuration forms service (`ctx.configForms`). */
36
+ forms: ConfigForms
37
+ /**
38
+ * Profile entry ids this package's rows carry, most likely first: the
39
+ * aggregate's generated row, the package's own standalone row, then the bare
40
+ * namespace. The first is the fallback while the mirror has no answer; the
41
+ * last is the answer when the mirror serves none of them.
42
+ */
43
+ entryIds: readonly string[]
44
+ }
45
+
46
+ /** The snapshot a form reports before the Host has answered with this entry. */
47
+ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
48
+ return {
49
+ status: 'loading',
50
+ value: undefined,
51
+ base: undefined,
52
+ user: undefined,
53
+ revision: undefined,
54
+ writable: false,
55
+ mode: 'host',
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Create a settings form bound to the profile entry id this Host serves, and
61
+ * rebound whenever the shared describe mirror names a different one of this
62
+ * package's rows.
63
+ * @param options - the shared forms service and this package's candidate row ids.
64
+ * @returns a form delegating to the currently resolved entry's own form.
65
+ */
66
+ export function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T> {
67
+ const { forms, entryIds } = options
68
+ const fallbackId = entryIds[0]
69
+ const namespaceId = entryIds[entryIds.length - 1]
70
+ const listeners = new Set<() => void>()
71
+ let boundId: string | undefined
72
+ let bound: ConfigForm<T> | undefined
73
+ let offBound: (() => void) | undefined
74
+ let snapshot: ConfigFormSnapshot<T> = pendingSnapshot<T>()
75
+
76
+ /** Republish: the delegated snapshot when one is bound, the pending one otherwise. */
77
+ const publish = (): void => {
78
+ if (bound !== undefined) snapshot = bound.getSnapshot()
79
+ for (const listener of [...listeners]) listener()
80
+ }
81
+
82
+ /**
83
+ * The entry id the mirror currently justifies. An unanswered or empty mirror
84
+ * is not evidence of absence, so it keeps the first candidate; a mirror that
85
+ * answers without any of this package's rows leaves the namespace itself.
86
+ * @returns the entry id to bind.
87
+ */
88
+ const resolve = (): string => {
89
+ let served: readonly string[] | undefined
90
+ try {
91
+ served = forms.describe().getSnapshot().view?.namespaces.map(row => row.ns)
92
+ } catch {
93
+ // A mirror that refuses the read is unanswered too, not absent.
94
+ served = undefined
95
+ }
96
+ if (served === undefined || served.length === 0) return fallbackId
97
+ return entryIds.find(id => served.includes(id)) ?? namespaceId
98
+ }
99
+
100
+ /** Bind (or rebind) the resolved entry's form; a no-op while it is unchanged. */
101
+ const bind = (): void => {
102
+ const target = resolve()
103
+ if (target === boundId) return
104
+ offBound?.()
105
+ offBound = undefined
106
+ let form: ConfigForm<T>
107
+ try {
108
+ form = forms.get<T>(target)
109
+ } catch {
110
+ // A service that refuses the id stays unbound; the next mirror answer
111
+ // retries rather than pinning a wrong entry for the session.
112
+ boundId = undefined
113
+ return
114
+ }
115
+ boundId = target
116
+ bound = form
117
+ offBound = form.subscribe(() => { publish() })
118
+ publish()
119
+ }
120
+
121
+ // The mirror subscription lives for the page: the card owns its form for the
122
+ // session, and the ConfigForm contract carries no disposer to hang it on. The
123
+ // callback is cheap (a resolve plus a no-op while the id is unchanged). A
124
+ // mirror without a subscription seat keeps the initial binding.
125
+ try {
126
+ forms.describe().subscribe(() => { bind() })
127
+ } catch {
128
+ // A describe face that refuses the subscription: the initial binding stands.
129
+ }
130
+ bind()
131
+
132
+ return {
133
+ getSnapshot: () => snapshot,
134
+ subscribe: (listener) => {
135
+ listeners.add(listener)
136
+ return () => { listeners.delete(listener) }
137
+ },
138
+ set: (field, value) => bound?.set(field, value) ?? Promise.resolve(false),
139
+ unset: (field) => bound?.unset(field) ?? Promise.resolve(false),
140
+ mutate: (ops, expectedRevision) => bound?.mutate(ops, expectedRevision) ?? Promise.resolve(false),
141
+ }
142
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import type {} from '@deepseek-ai/dsh-host-webserver'
3
3
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
4
- import z from 'schemastery'
4
+ import z from '@deepseek-ai/schemastery'
5
5
  import { mountOnce } from './mount-once.ts'
6
6
  import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
7
7
  import { makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
8
8
 
9
+ declare module '@deepseek-ai/cordis' {
10
+ interface Events {
11
+ /**
12
+ * Volatile config values were committed into the running fiber without a
13
+ * remount; dispatched to the owning fiber only. Spelled here because the
14
+ * Loader package is not a dependency of this plugin, with the Loader's own
15
+ * shape so the two declarations merge when a Host program carries both.
16
+ * @param paths - changed config paths as key arrays; every value is committed before dispatch.
17
+ * @mode emit
18
+ */
19
+ 'loader/volatile-update'(paths: readonly (readonly string[])[]): void
20
+ }
21
+ }
22
+
9
23
  export const name = 'dsh-usage'
10
24
  export const inject = ['webServer']
11
25
  /**
@@ -16,29 +30,75 @@ export const inject = ['webServer']
16
30
  */
17
31
  export const USAGE_SETTINGS_NAMESPACE = 'dsh-usage' as SettingsNamespace
18
32
 
19
- export interface Config {
20
- enabled?: boolean
33
+ /** The stable reference a `volatile()` config field resolves to; its owner updates it in place. */
34
+ interface ConfigRef<T> {
35
+ /** @returns the value currently committed for the running instance. */
36
+ get(): T | undefined
37
+ }
38
+
39
+ /** One config field as the Host hands it over: a live reference, or a plain value (profile patches, tests). */
40
+ type ConfigField<T> = ConfigRef<T> | T | undefined
41
+
42
+ /** The config the Host hands this activation — the runtime face of {@link Config}. */
43
+ export interface UsageConfigFields {
44
+ enabled?: ConfigField<boolean>
21
45
  /** Provider probe cycle in seconds; 30-3600. */
22
- pollIntervalSec?: number
46
+ pollIntervalSec?: ConfigField<number>
23
47
  /** Ledger retention in local days. */
24
- retainDays?: number
48
+ retainDays?: ConfigField<number>
25
49
  }
26
50
 
27
- export const Config: z<Config> = z.object({
28
- enabled: z.boolean().default(true),
29
- pollIntervalSec: z.number().min(30).max(3600).default(60),
30
- retainDays: z.number().min(7).max(730).default(180),
51
+ /**
52
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
53
+ * entry's settings page: the Host derives one form per profile entry from it
54
+ * and serves it only when at least one field is `volatile()`. The marker is
55
+ * also what admits a write and what keeps the edit on the live path — the
56
+ * Loader commits the new value into the field's reference and announces
57
+ * `loader/volatile-update` on this fiber instead of remounting the row, so the
58
+ * probe cycle, its ledger and its route registrations survive a settings save.
59
+ *
60
+ * The schema is left to inference rather than annotated with
61
+ * `z<UsageConfigFields>`: a volatile field's parsed output is a reference while
62
+ * its accepted input stays the plain value, so the two sides no longer share
63
+ * one shape and the annotation would reject the schema the Host must be given.
64
+ */
65
+ export const Config = z.object({
66
+ enabled: z.boolean().default(true).volatile(),
67
+ pollIntervalSec: z.number().min(30).max(3600).default(60).volatile(),
68
+ retainDays: z.number().min(7).max(730).default(180).volatile(),
31
69
  })
32
70
 
33
71
  export interface ResolvedConfig extends UsageServiceOptions {
34
72
  enabled: boolean
35
73
  }
36
74
 
37
- export function resolveConfig(config?: Config): ResolvedConfig {
75
+ /** Read one config field, following the live reference the schema produces. */
76
+ function readConfigField<T>(field: ConfigField<T>): T | undefined {
77
+ if (field === undefined) return undefined
78
+ if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
79
+ return (field as ConfigRef<T>).get()
80
+ }
81
+ return field as T
82
+ }
83
+
84
+ /** Read one numeric field, falling back when the value is absent or not a usable number. */
85
+ function readNumber(field: ConfigField<number>, fallback: number): number {
86
+ const value = readConfigField(field)
87
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
88
+ }
89
+
90
+ /**
91
+ * Resolve the effective settings of one activation. Volatile references are
92
+ * read here, so every re-arm observes the latest committed values rather than
93
+ * whatever the row was activated with.
94
+ * @param config - the config the Host passed to the activation.
95
+ * @returns the resolved settings.
96
+ */
97
+ export function resolveConfig(config?: UsageConfigFields): ResolvedConfig {
38
98
  return {
39
- enabled: config?.enabled ?? true,
40
- pollIntervalSec: typeof config?.pollIntervalSec === 'number' ? config.pollIntervalSec : 60,
41
- retainDays: typeof config?.retainDays === 'number' ? config.retainDays : 180,
99
+ enabled: readConfigField(config?.enabled) ?? true,
100
+ pollIntervalSec: readNumber(config?.pollIntervalSec, 60),
101
+ retainDays: readNumber(config?.retainDays, 180),
42
102
  }
43
103
  }
44
104
 
@@ -54,31 +114,23 @@ let pendingStop: Promise<unknown> | undefined
54
114
  /**
55
115
  * Host body. The plugin's own Config schema above is the settings document the
56
116
  * 0.1.7 Host serves for this row; the effective config arrives here as the
57
- * `config` argument the Loader resolved and the Host reloads the row when the
58
- * user saves a change, so nothing in this body observes or writes settings.
117
+ * `config` argument, and a settings save re-enters this body through the
118
+ * volatile path rather than a row reload (see {@link Config}).
59
119
  * @param ctx - host root context.
60
120
  * @param config - the row's resolved configuration.
61
121
  */
62
- export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: Config): void => {
63
- const value = resolveConfig(config)
64
- // A disabled row mounts nothing: no probe cycle, no ledger, no routes. The
65
- // settings page owns the enable switch, and re-enabling reloads this row.
66
- if (!value.enabled) return
67
-
122
+ export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: UsageConfigFields): void => {
68
123
  let live = true
69
124
  let service: UsageService | undefined
70
125
  let disposeRoutes: (() => void) | undefined
126
+ /** True while this activation has a start queued behind a predecessor's flush. */
127
+ let queued = false
71
128
 
72
- const begin = (): void => {
73
- // The fiber may have gone down while the predecessor's final flush was
74
- // still pending; only start while this activation is the live one.
75
- if (!live) return
76
- const next = new UsageService(ctx, value)
77
- service = next
78
- next.start()
129
+ /** Register the service's two routes; the returned disposer unregisters them. */
130
+ const mountRoutes = (next: UsageService): (() => void) => {
79
131
  const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
80
132
  .map((route) => ctx.webServer.register(route))
81
- disposeRoutes = () => {
133
+ return () => {
82
134
  for (const dispose of disposers) {
83
135
  try {
84
136
  dispose()
@@ -89,25 +141,68 @@ export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: C
89
141
  }
90
142
  }
91
143
 
92
- ctx.effect(() => {
144
+ /** Start a fresh activation, unless this fiber is down or one already runs. */
145
+ const launch = (): void => {
146
+ queued = false
147
+ if (!live || service !== undefined) return
148
+ const value = resolveConfig(config)
149
+ if (!value.enabled) return
150
+ const next = new UsageService(ctx, value)
151
+ service = next
152
+ next.start()
153
+ disposeRoutes = mountRoutes(next)
154
+ }
155
+
156
+ /** Tear the running activation down, chaining its final flush for any successor. */
157
+ const stop = (): void => {
158
+ disposeRoutes?.()
159
+ disposeRoutes = undefined
160
+ const stopping = service?.stop()
161
+ service = undefined
162
+ if (stopping !== undefined) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping)
163
+ }
164
+
165
+ /**
166
+ * Apply the activation's current settings: a disabled row mounts nothing (no
167
+ * probe cycle, no ledger, no routes), an enabled one starts on first use and
168
+ * otherwise re-applies its options in place.
169
+ */
170
+ const rearm = (): void => {
171
+ const value = resolveConfig(config)
172
+ if (!value.enabled) {
173
+ queued = false
174
+ stop()
175
+ return
176
+ }
177
+ if (service !== undefined) {
178
+ // Retention shrink prunes now; the poll loop reads the new cadence when
179
+ // its next cycle re-arms.
180
+ service.applyOptions(value)
181
+ return
182
+ }
183
+ if (queued) return
93
184
  if (pendingStop === undefined) {
94
185
  // No predecessor's flush is in flight: start now.
95
- begin()
96
- } else {
97
- // Serialize the start behind every predecessor: their final writes must
98
- // land on disk before this instance loads the file.
99
- pendingStop = pendingStop.then(begin, begin).then(() => undefined)
186
+ launch()
187
+ return
100
188
  }
189
+ // Serialize the start behind every predecessor: their final writes must
190
+ // land on disk before this instance loads the file. `launch` re-reads the
191
+ // settings, so a disable that arrives while this is queued still wins.
192
+ queued = true
193
+ pendingStop = pendingStop.then(launch, launch).then(() => undefined)
194
+ }
195
+
196
+ // The settings write path: the Host committed new values into this row's
197
+ // volatile references without remounting it, so re-arm from them.
198
+ ctx.on('loader/volatile-update', () => { rearm() })
199
+
200
+ ctx.effect(() => {
201
+ rearm()
101
202
  return () => {
102
203
  live = false
103
- disposeRoutes?.()
104
- disposeRoutes = undefined
105
- const stopping = service?.stop()
106
- service = undefined
107
- // A successor waits for this instance's final flush, which itself waited
108
- // for the predecessor's start: a row disposed before it ever started
109
- // keeps the earlier link instead of dropping it.
110
- if (stopping !== undefined) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping)
204
+ queued = false
205
+ stop()
111
206
  }
112
207
  }, 'dsh-usage: runtime')
113
208
  })
package/src/mount-once.ts CHANGED
@@ -5,44 +5,138 @@
5
5
  * the loader accepts a standalone install of the same package side by side;
6
6
  * without this guard the second instance would still re-register the same
7
7
  * webserver routes, tools, settings namespaces, and system-prompt sections
8
- * and fail the boot. mountOnce makes the second host apply a no-op for the
9
- * lifetime of the first instance (the browser half is already deduped by
10
- * package name in the client module host).
8
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
9
+ * no-op for as long as the first instance lives (the browser half is already
10
+ * deduped by package name in the client module host).
11
+ *
12
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
13
+ * long after the refused mount ran its course: the Host reloads a profile by
14
+ * creating the new loader entries before the old ones are torn down (a
15
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
16
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
17
+ * previous entry still owns the name. Dropping that refused mount lost the
18
+ * plugin for good - the previous entry then disposed its own mount, releasing
19
+ * the name with nobody left to take it, and the family row stayed listed as
20
+ * active while its host routes 404ed (the task-board panel showed
21
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
22
+ * restart recovered it).
23
+ *
24
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
25
+ * the holder releases the name - if the waiting fiber is still alive then. The
26
+ * single-instance guarantee is unchanged: exactly one mount is live per
27
+ * package name, and the replay re-enters the guard so a later mount still
28
+ * dedupes against it.
11
29
  *
12
30
  * The registry rides a global symbol so two module instances of the same
13
- * package (npm copy vs repository link) still share one verdict. cordis
14
- * `ctx.effect` runs its callback immediately and treats the callback's
31
+ * package (npm copy vs repository link) still share one verdict. That symbol
32
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
33
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
34
+ * dsh-community-plugins) are separate repositories carrying their own copy of
35
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
36
+ * must keep the shape every published copy reads (a `Set` of package names
37
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
38
+ * under their own additive key, and the registry reads back a `Set` even when
39
+ * some other build left a different value there. Changing `MOUNTED`'s shape
40
+ * in place broke that contract once: a satellite's legacy copy created a
41
+ * `Set`, the family's new copy read it as a `Map`, and every family row
42
+ * mounted after it failed with "claims.get is not a function".
43
+ *
44
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
15
45
  * return value as the fiber disposer, so the unmarker is returned, not run.
16
46
  */
17
47
 
48
+ /** Published cross-repository contract: package names currently mounted. */
18
49
  const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
19
50
 
51
+ /** Additive key this guard owns: refused mounts waiting for the name. */
52
+ const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
53
+
54
+ /** The slice of a cordis context this guard touches. */
55
+ interface MountContext {
56
+ effect?: (effect: () => unknown) => unknown
57
+ }
58
+
59
+ /** One mount that was refused while another fiber owned the package name. */
60
+ interface PendingMount {
61
+ /** Replay the refused mount; a no-op once its own fiber has been disposed. */
62
+ run(): void
63
+ }
64
+
20
65
  interface MountRegistry {
21
- [MOUNTED]?: Set<string>
66
+ [MOUNTED]?: unknown
67
+ [WAITERS]?: Map<string, PendingMount[]>
22
68
  }
23
69
 
70
+ /**
71
+ * The shared name registry, always a `Set` whatever another build stored here:
72
+ * a foreign value (an interim shape, a hand-written global) must not take every
73
+ * family plugin down with it.
74
+ * @returns the process-wide set of mounted package names.
75
+ */
24
76
  function mountedSet(): Set<string> {
25
77
  const registry = globalThis as MountRegistry
26
- return (registry[MOUNTED] ??= new Set())
78
+ const existing = registry[MOUNTED]
79
+ if (existing instanceof Set) return existing as Set<string>
80
+ const created = new Set<string>()
81
+ registry[MOUNTED] = created
82
+ return created
83
+ }
84
+
85
+ /** Queue per package name for mounts refused while a holder was alive. */
86
+ function mountWaiters(): Map<string, PendingMount[]> {
87
+ const registry = globalThis as MountRegistry
88
+ return (registry[WAITERS] ??= new Map())
27
89
  }
28
90
 
29
91
  /**
30
92
  * Wrap a cordis plugin apply so the package runs at most once per process.
31
- * The first mount registers normally and unmarks when its fiber disposes;
32
- * any later mount of the same package name is a no-op.
93
+ * The first mount registers normally and releases the name when its fiber
94
+ * disposes; a mount refused while that name is held waits for the release and
95
+ * then runs, unless its own fiber disposes first.
33
96
  * @param packageName - npm package identity shared by every install source.
34
97
  * @param fn - the original plugin apply.
35
98
  * @returns an apply of the same shape.
36
99
  */
37
100
  export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
- return ((...args: unknown[]) => {
101
+ const mount = (...args: unknown[]): unknown => {
39
102
  const mounted = mountedSet()
40
- if (mounted.has(packageName)) return
103
+ const ctx = args[0] as MountContext | undefined
104
+ if (mounted.has(packageName)) {
105
+ // Another fiber (the aggregate row, a standalone install, or a copy built
106
+ // from an older revision of this guard) owns the name. Queue instead of
107
+ // dropping: dropping lost the plugin for the rest of the process when the
108
+ // holder was disposed by a reload.
109
+ const waiters = mountWaiters()
110
+ const queue = waiters.get(packageName) ?? []
111
+ let alive = true
112
+ const pending: PendingMount = {
113
+ run: () => {
114
+ if (alive) mount(...args)
115
+ },
116
+ }
117
+ // A fiber that dies while it waits must not be replayed afterwards: its
118
+ // context is disposed and registering effects on it would throw.
119
+ ctx?.effect?.(() => () => {
120
+ alive = false
121
+ const index = queue.indexOf(pending)
122
+ if (index >= 0) queue.splice(index, 1)
123
+ })
124
+ queue.push(pending)
125
+ waiters.set(packageName, queue)
126
+ return
127
+ }
41
128
  mounted.add(packageName)
42
- const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
129
  ctx?.effect?.(() => () => {
44
130
  mounted.delete(packageName)
131
+ const waiters = mountWaiters()
132
+ const queue = waiters.get(packageName)
133
+ if (queue === undefined) return
134
+ waiters.delete(packageName)
135
+ // Defer the handover one microtask so the disposing mount's own effects
136
+ // (routes, ledger, timers) are torn down before the next one registers.
137
+ for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
45
138
  })
46
139
  return fn(...args)
47
- }) as T
140
+ }
141
+ return mount as T
48
142
  }