diff --git a/packages/loader/src/entry.ts b/packages/loader/src/entry.ts index 9d87d30..1e43065 100644 --- a/packages/loader/src/entry.ts +++ b/packages/loader/src/entry.ts @@ -9,18 +9,18 @@ export namespace Entry { config?: any disabled?: boolean | null intercept?: Dict | null - isolate?: Dict + isolate?: Dict | null when?: any } } -function swap(target: T, source?: T | null): T { - const result = { ...target } - for (const key in result) { - delete target[key] +function swap(target: T, source?: T | null) { + for (const key of Reflect.ownKeys(target)) { + Reflect.deleteProperty(target, key) + } + for (const key of Reflect.ownKeys(source || {})) { + Reflect.defineProperty(target, key, Reflect.getOwnPropertyDescriptor(source!, key)!) } - Object.assign(target, source) - return result } function takeEntries(object: {}, keys: string[]) { @@ -62,30 +62,18 @@ export class Entry { } } - patch(ctx?: Context, legacy?: Entry.Options) { - ctx ??= this.parent.extend({ - [Context.intercept]: Object.create(this.parent[Context.intercept]), - [Context.isolate]: Object.create(this.parent[Context.isolate]), - }) - ctx.emit('loader/patch', this, legacy) - swap(ctx[Context.intercept], this.options.intercept) - + patch(ctx: Context, ref: Context = ctx) { // part 1: prepare isolate map - const newMap: Dict = Object.create(Object.getPrototypeOf(ctx[Context.isolate])) + const newMap: Dict = Object.create(Object.getPrototypeOf(ref[Context.isolate])) for (const [key, label] of Object.entries(this.options.isolate ?? {})) { const realm = this.resolveRealm(label) newMap[key] = (this.loader.realms[realm] ??= Object.create(null))[key] ??= Symbol(`${key}${realm}`) } - for (const [key, label] of Object.entries(legacy?.isolate ?? {})) { - if (this.options.isolate?.[key] === label) continue - const name = this.resolveRealm(label) - this.loader._clearRealm(key, name) - } // part 2: generate service diff const diff: [string, symbol, symbol, symbol, symbol][] = [] const oldMap = ctx[Context.isolate] - for (const key in { ...oldMap, ...newMap }) { + for (const key in { ...oldMap, ...newMap, ...this.loader.delims }) { if (newMap[key] === oldMap[key]) continue const delim = this.loader.delims[key] ??= Symbol(key) ctx[delim] = Symbol(`${key}#${this.options.id}`) @@ -114,8 +102,14 @@ export class Entry { } // part 3.2: update service impl, prevent double update - this.fork?.update(this.options.config) - swap(ctx[Context.isolate], newMap) + swap(ctx[Context.intercept], this.options.intercept) + if (ctx === ref) { + this.fork?.update(this.options.config) + swap(ctx[Context.isolate], newMap) + } else { + Object.setPrototypeOf(ctx, Object.getPrototypeOf(ref)) + swap(ctx, ref) + } for (const [, symbol1, symbol2, flag1, flag2] of diff) { if (flag1 === flag2 && ctx[symbol1] && !ctx[symbol2]) { ctx.root[symbol2] = ctx.root[symbol1] @@ -139,7 +133,13 @@ export class Entry { delete ctx[this.loader.delims[key]] } } - return ctx + } + + createContext() { + return this.parent.extend({ + [Context.intercept]: Object.create(this.parent[Context.intercept]), + [Context.isolate]: Object.create(this.parent[Context.isolate]), + }) } async update(parent: Context, options: Entry.Options) { @@ -150,12 +150,18 @@ export class Entry { this.stop() } else if (this.fork) { this.isUpdate = true - this.patch(this.fork.parent, legacy) + for (const [key, label] of Object.entries(legacy.isolate ?? {})) { + if (this.options.isolate?.[key] === label) continue + const name = this.resolveRealm(label) + this.loader._clearRealm(key, name) + } + this.patch(this.fork.parent) } else { this.parent.emit('loader/entry', 'apply', this) const plugin = await this.loader.resolve(this.options.name) if (!plugin) return - const ctx = this.patch() + const ctx = this.createContext() + this.patch(ctx) this.fork = ctx.plugin(plugin, this.options.config) this.fork.entry = this } diff --git a/packages/loader/src/shared.ts b/packages/loader/src/shared.ts index 5e1541b..4b637c8 100644 --- a/packages/loader/src/shared.ts +++ b/packages/loader/src/shared.ts @@ -284,7 +284,7 @@ export abstract class Loader extends this.writeConfig() } - teleport(id: string, target: string, index = Infinity) { + transfer(id: string, target: string, index = Infinity) { const entry = this.entries[id] if (!entry) throw new Error(`entry ${id} not found`) const sourceEntry = entry.parent.scope.entry! @@ -296,7 +296,8 @@ export abstract class Loader extends if (sourceEntry === targetEntry) return entry.parent = targetEntry.fork.ctx if (!entry.fork) return - entry.patch() + const ctx = entry.createContext() + entry.patch(entry.fork.parent, ctx) } paths(scope: EffectScope): string[] { diff --git a/packages/loader/tests/isolate.spec.ts b/packages/loader/tests/isolate.spec.ts index 45a7f52..690e749 100644 --- a/packages/loader/tests/isolate.spec.ts +++ b/packages/loader/tests/isolate.spec.ts @@ -15,42 +15,36 @@ describe('service isolation: basic', async () => { ctx.on('dispose', dispose) }, 'inject', ['bar'])) - const Bar = loader.mock('bar', class Bar extends Service { + loader.mock('bar', class Bar extends Service { static [Service.provide] = 'bar' static [Service.immediate] = true }) before(() => loader.start()) + beforeEach(() => { + foo.mock.resetCalls() + dispose.mock.resetCalls() + }) + + let provider!: string + let injector!: string + it('initiate', async () => { - loader.root.fork!.update([{ - id: '1', - name: 'bar', - }, { - id: '3', - name: 'foo', - }]) + provider = await loader.create({ name: 'bar' }) + injector = await loader.create({ name: 'foo' }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(root.registry.get(foo)).to.be.ok - expect(root.registry.get(Bar)).to.be.ok expect(foo.mock.calls).to.have.length(1) expect(dispose.mock.calls).to.have.length(0) }) it('add isolate on injector (relavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', - }, { - id: '3', - name: 'foo', + await loader.update(injector, { isolate: { bar: true, }, - }]) + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(0) @@ -58,19 +52,12 @@ describe('service isolation: basic', async () => { }) it('add isolate on injector (irrelavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', - }, { - id: '3', - name: 'foo', + await loader.update(injector, { isolate: { bar: true, qux: true, }, - }]) + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(0) @@ -78,18 +65,11 @@ describe('service isolation: basic', async () => { }) it('remove isolate on injector (relavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', - }, { - id: '3', - name: 'foo', + await loader.update(injector, { isolate: { qux: true, }, - }]) + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(1) @@ -97,15 +77,9 @@ describe('service isolation: basic', async () => { }) it('remove isolate on injector (irrelavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', - }, { - id: '3', - name: 'foo', - }]) + await loader.update(injector, { + isolate: null, + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(0) @@ -113,18 +87,11 @@ describe('service isolation: basic', async () => { }) it('add isolate on provider (relavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', + await loader.update(provider, { isolate: { bar: true, }, - }, { - id: '3', - name: 'foo', - }]) + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(0) @@ -132,19 +99,12 @@ describe('service isolation: basic', async () => { }) it('add isolate on provider (irrelavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', + await loader.update(provider, { isolate: { bar: true, qux: true, }, - }, { - id: '3', - name: 'foo', - }]) + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(0) @@ -152,15 +112,11 @@ describe('service isolation: basic', async () => { }) it('remove isolate on provider (relavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', - }, { - id: '3', - name: 'foo', - }]) + await loader.update(provider, { + isolate: { + qux: true, + }, + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(1) @@ -168,15 +124,9 @@ describe('service isolation: basic', async () => { }) it('remove isolate on provider (irrelavent)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - loader.root.fork!.update([{ - id: '1', - name: 'bar', - }, { - id: '3', - name: 'foo', - }]) + await loader.update(provider, { + isolate: null, + }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(foo.mock.calls).to.have.length(0) @@ -206,13 +156,15 @@ describe('service isolation: realm', async () => { before(() => loader.start()) + beforeEach(() => { + foo.mock.resetCalls() + dispose.mock.resetCalls() + }) + let alpha!: string let beta!: string it('add isolate group', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - alpha = await loader.create({ name: 'cordis/group', isolate: { @@ -242,9 +194,6 @@ describe('service isolation: realm', async () => { }) it('update isolate group (no change)', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - await loader.update(alpha, { isolate: { bar: true, @@ -262,9 +211,6 @@ describe('service isolation: realm', async () => { let nested3!: string it('realm reference', async () => { - foo.mock.resetCalls() - dispose.mock.resetCalls() - nested1 = await loader.create({ name: 'foo', }, alpha) @@ -522,3 +468,80 @@ describe('service isolation: realm', async () => { expect(fork2.ctx.get('bar')).to.be.ok }) }) + +describe('service isolation: 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) + }, 'inject', ['bar'])) + + loader.mock('bar', class Bar extends Service { + static [Service.provide] = 'bar' + static [Service.immediate] = true + }) + + before(() => loader.start()) + + beforeEach(() => { + foo.mock.resetCalls() + dispose.mock.resetCalls() + }) + + let group!: string + let provider!: string + let injector!: string + + it('initiate', async () => { + group = await loader.create({ + name: 'cordis/group', + isolate: { + bar: true, + }, + config: [], + }) + + provider = await loader.create({ name: 'bar' }) + injector = await loader.create({ name: 'foo' }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(foo.mock.calls).to.have.length(1) + expect(dispose.mock.calls).to.have.length(0) + }) + + it('transfer injector into group', async () => { + loader.transfer(injector, group) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(foo.mock.calls).to.have.length(0) + expect(dispose.mock.calls).to.have.length(1) + }) + + it('transfer provider into group', async () => { + loader.transfer(provider, group) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(foo.mock.calls).to.have.length(1) + expect(dispose.mock.calls).to.have.length(0) + }) + + it('transfer injector out of group', async () => { + loader.transfer(injector, '') + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(foo.mock.calls).to.have.length(0) + expect(dispose.mock.calls).to.have.length(1) + }) + + it('transfer provider out of group', async () => { + loader.transfer(provider, '') + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(foo.mock.calls).to.have.length(1) + expect(dispose.mock.calls).to.have.length(0) + }) +})