From 1641e2e297a3aac4a507a4e0b12453f7aadd40bf Mon Sep 17 00:00:00 2001 From: Shigma Date: Fri, 31 May 2024 01:21:39 +0800 Subject: [PATCH] feat(loader): support options.group --- packages/loader/src/entry.ts | 96 +++++++++------- packages/loader/src/loader.ts | 22 +++- packages/loader/tests/group.spec.ts | 164 ++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 44 deletions(-) create mode 100644 packages/loader/tests/group.spec.ts diff --git a/packages/loader/src/entry.ts b/packages/loader/src/entry.ts index 12effb5..5d15f3b 100644 --- a/packages/loader/src/entry.ts +++ b/packages/loader/src/entry.ts @@ -62,6 +62,56 @@ export class Entry { return id } + get requiredDeps() { + return Array.isArray(this.options.inject) + ? this.options.inject + : this.options.inject?.required ?? [] + } + + get deps() { + return Array.isArray(this.options.inject) + ? this.options.inject + : [ + ...this.options.inject?.required ?? [], + ...this.options.inject?.optional ?? [], + ] + } + + get disabled() { + // group is always enabled + if (this.options.group) return false + let entry: Entry | undefined = this + do { + if (entry.options.disabled) return true + entry = entry.parent.ctx.scope.entry + } while (entry) + return false + } + + _check() { + if (this.disabled) return false + for (const name of this.requiredDeps) { + let key = this.parent.ctx[Context.isolate][name] + const label = this.options.isolate?.[name] + if (label) { + const realm = this.resolveRealm(label) + key = (this.loader.realms[realm] ?? Object.create(null))[name] ?? Symbol(`${name}${realm}`) + } + if (!key || isNullable(this.parent.ctx[key])) return false + } + return true + } + + async checkService(name: string) { + if (!this.requiredDeps.includes(name)) return + const ready = this._check() + if (ready && !this.fork) { + await this.start() + } else if (!ready && this.fork) { + await this.stop() + } + } + resolveRealm(label: string | true) { if (label === true) { return '#' + this.id @@ -131,6 +181,13 @@ export class Entry { if (this.fork && 'config' in options) { this.suspend = true this.fork.update(this.options.config) + } else if (this.subgroup && 'disabled' in options) { + const tree = this.subtree ?? this.parent.tree + for (const options of this.subgroup.data) { + tree.store[options.id].update({ + disabled: options.disabled, + }) + } } // step 4.3: replace service impl @@ -161,45 +218,6 @@ export class Entry { return ctx } - get requiredDeps() { - return Array.isArray(this.options.inject) - ? this.options.inject - : this.options.inject?.required ?? [] - } - - get deps() { - return Array.isArray(this.options.inject) - ? this.options.inject - : [ - ...this.options.inject?.required ?? [], - ...this.options.inject?.optional ?? [], - ] - } - - _check() { - if (this.options.disabled) return false - for (const name of this.requiredDeps) { - let key = this.parent.ctx[Context.isolate][name] - const label = this.options.isolate?.[name] - if (label) { - const realm = this.resolveRealm(label) - key = (this.loader.realms[realm] ?? Object.create(null))[name] ?? Symbol(`${name}${realm}`) - } - if (!key || isNullable(this.parent.ctx[key])) return false - } - return true - } - - async checkService(name: string) { - if (!this.requiredDeps.includes(name)) return - const ready = this._check() - if (ready && !this.fork) { - await this.start() - } else if (!ready && this.fork) { - await this.stop() - } - } - async update(options: Partial, override = false) { const legacy = { ...this.options } diff --git a/packages/loader/src/loader.ts b/packages/loader/src/loader.ts index edb0bc5..cefa187 100644 --- a/packages/loader/src/loader.ts +++ b/packages/loader/src/loader.ts @@ -81,17 +81,29 @@ export abstract class Loader extends ImportTree { }) this.ctx.on('internal/fork', (fork) => { + // 1. set `fork.entry` if (fork.parent[Entry.key]) { fork.entry = fork.parent[Entry.key] delete fork.parent[Entry.key] } - // fork.uid: fork is created (we only care about fork dispose event) - // fork.parent.runtime.plugin !== group: fork is not tracked by loader - if (fork.uid || !fork.entry) return - // fork is disposed by main scope (e.g. hmr plugin) - // normal: ctx.dispose() -> fork / runtime dispose -> delete(plugin) + + // 2. handle self-dispose + // We only care about `ctx.scope.dispose()`, so we need to filter out other cases. + + // case 1: fork is created + if (fork.uid) return + + // case 2: fork is not tracked by loader + if (!fork.entry) return + + // case 3: fork is disposed outside of plugin + // self-dispose: ctx.scope.dispose() -> fork / runtime dispose -> delete(plugin) // hmr: delete(plugin) -> runtime dispose -> fork dispose if (!this.ctx.registry.has(fork.runtime.plugin)) return + + // case 4: fork is disposed by inject checker / config file hmr / ancestor group + if (!fork.entry._check()) return + fork.parent.emit('loader/entry', 'unload', fork.entry) fork.entry.options.disabled = true fork.entry.fork = undefined diff --git a/packages/loader/tests/group.spec.ts b/packages/loader/tests/group.spec.ts new file mode 100644 index 0000000..d248b0c --- /dev/null +++ b/packages/loader/tests/group.spec.ts @@ -0,0 +1,164 @@ +import { mock } from 'node:test' +import { expect } from 'chai' +import { Context } from '@cordisjs/core' +import { defineProperty } from 'cosmokit' +import MockLoader from './utils' + +describe('group management: basic support', () => { + const root = new Context() + root.plugin(MockLoader) + const loader = root.loader + + const dispose = mock.fn() + const foo = loader.mock('foo', defineProperty((ctx: Context) => { + ctx.on('dispose', dispose) + }, 'reusable', true)) + + before(() => loader.start()) + + beforeEach(() => { + foo.mock.resetCalls() + dispose.mock.resetCalls() + }) + + let outer!: string + let inner!: string + + it('initialize', async () => { + outer = await loader.create({ + name: 'cordis/group', + group: true, + config: [{ + name: 'foo', + }], + }) + + inner = await loader.create({ + name: 'cordis/group', + group: true, + config: [{ + name: 'foo', + }], + }, outer) + + expect(foo.mock.calls).to.have.length(2) + expect(dispose.mock.calls).to.have.length(0) + expect([...loader.entries()]).to.have.length(4) + }) + + it('disable inner', async () => { + await loader.update(inner, { disabled: true }) + + expect(foo.mock.calls).to.have.length(0) + expect(dispose.mock.calls).to.have.length(1) + expect([...loader.entries()]).to.have.length(4) + }) + + it('disable outer', async () => { + await loader.update(outer, { disabled: true }) + + expect(foo.mock.calls).to.have.length(0) + expect(dispose.mock.calls).to.have.length(1) + expect([...loader.entries()]).to.have.length(4) + }) + + it('enable inner', async () => { + await loader.update(inner, { disabled: null }) + + expect(foo.mock.calls).to.have.length(0) // outer is still disabled + expect(dispose.mock.calls).to.have.length(0) + expect([...loader.entries()]).to.have.length(4) + }) + + it('enable outer', async () => { + await loader.update(outer, { disabled: null }) + + expect(foo.mock.calls).to.have.length(2) + expect(dispose.mock.calls).to.have.length(0) + expect([...loader.entries()]).to.have.length(4) + }) +}) + +describe('group management: transfer', () => { + const root = new Context() + root.plugin(MockLoader) + const loader = root.loader + + const dispose = mock.fn() + const foo = loader.mock('foo', defineProperty((ctx: Context) => { + ctx.on('dispose', dispose) + }, 'reusable', true)) + + before(() => loader.start()) + + beforeEach(() => { + foo.mock.resetCalls() + dispose.mock.resetCalls() + }) + + let alpha!: string + let beta!: string + let gamma!: string + let id!: string + + it('initialize', async () => { + id = await loader.create({ + name: 'foo', + }) + + alpha = await loader.create({ + name: 'cordis/group', + group: true, + config: [], + }) + + beta = await loader.create({ + name: 'cordis/group', + group: true, + disabled: true, + config: [], + }, alpha) + + gamma = await loader.create({ + name: 'cordis/group', + group: true, + config: [], + }, beta) + + expect(foo.mock.calls).to.have.length(1) + expect(dispose.mock.calls).to.have.length(0) + expect([...loader.entries()]).to.have.length(4) + }) + + it('enabled -> enabled', async () => { + await loader.update(id, {}, alpha) + + expect(foo.mock.calls).to.have.length(0) + expect(dispose.mock.calls).to.have.length(0) + expect([...loader.entries()]).to.have.length(4) + }) + + it('enabled -> disabled', async () => { + await loader.update(id, {}, beta) + + expect(foo.mock.calls).to.have.length(0) + expect(dispose.mock.calls).to.have.length(1) + expect([...loader.entries()]).to.have.length(4) + }) + + it('disabled -> disabled', async () => { + await loader.update(id, {}, gamma) + + expect(foo.mock.calls).to.have.length(0) // outer is still disabled + expect(dispose.mock.calls).to.have.length(0) + expect([...loader.entries()]).to.have.length(4) + }) + + it('disabled -> enabled', async () => { + await loader.update(id, {}, null) + + expect(foo.mock.calls).to.have.length(1) + expect(dispose.mock.calls).to.have.length(0) + expect([...loader.entries()]).to.have.length(4) + }) +})