diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 233a6734cb..95d43e756f 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -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 { /** @@ -578,6 +592,13 @@ export function installSettingsSection( 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() }) diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 76d40b77d6..5f9ac7dae7 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -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']) + }) })