fix(settings): keep installSettingsSection quiet when its consumer unloads

The helper's cleanup ran the same fallback for two different events. A
settings provider detaching leaves the consumer running, so falling back
to the composition entry and re-judging derived facts is right. The
consumer's own unload ran it too — re-registering routes and touching
resources the teardown was releasing. The disposer now checks the
consumer fiber's own state and returns when it is unloading or disposed.
This commit is contained in:
Yichen Jiang
2026-07-30 15:52:51 +08:00
parent 54f95d7669
commit d91f0227e6
2 changed files with 51 additions and 0 deletions
+21
View File
@@ -538,6 +538,20 @@ export abstract class Settings extends Service {
}
}
/**
* Value mirror of the `FiberState` members {@link isUnloading} compares
* against: a const enum has no runtime object to import, and the value is
* needed at runtime (same rationale as the CLI boot driver's mirror).
*/
const FIBER_DISPOSED = 4
const FIBER_UNLOADING = 5
/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
function isUnloading(ctx: Context): boolean {
const state: number = ctx.fiber.state
return state === FIBER_UNLOADING || state === FIBER_DISPOSED
}
/** Hooks a consumer hands to {@link installSettingsSection}. */
export interface SettingsSectionHooks<T> {
/**
@@ -578,6 +592,13 @@ export function installSettingsSection<T>(
const scope = sctx.settings.register(ns, schema, { base: entry })
hooks.setSource(() => scope.get())
sctx.effect(() => () => {
// This disposer runs for two different reasons. A settings provider
// detaching leaves the consumer running, so it must fall back to its
// composition entry and re-judge what it derived. The consumer's own
// unload runs it too — and there `onChange` would re-register routes
// and touch resources the teardown is releasing, so the fallback is
// pointless and the notification actively harmful.
if (isUnloading(ctx)) return
hooks.setSource(() => entry)
hooks.onChange()
})
@@ -694,4 +694,34 @@ describe('installSettingsSection', () => {
})
expect(current()).toEqual({ theme: 'entry' })
})
it('stays silent when the consumer itself unloads', async () => {
const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } })
const entry = { theme: 'entry' }
let current: () => { theme: string } = () => entry
const changes: string[] = []
const consumer = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
setSource: (source) => {
current = source
},
onChange: () => {
changes.push(current().theme)
},
})
},
})
await consumer
await vi.waitFor(() => {
expect(changes).toEqual(['user'])
})
// The consumer's own teardown must not re-derive anything: an onChange
// here would re-register routes and touch resources being released.
await consumer.dispose()
await new Promise(resolve => setTimeout(resolve, 20))
expect(changes).toEqual(['user'])
})
})