diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index e5aff58f5a..ba0e521295 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -42,11 +42,10 @@ declare module '@deepseek-ai/cordis' { /** * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. - * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's `dsh.client` - * declaration and reach fibers through entry creation). `external` carries - * module-graph edges: unlike `inject`, they constrain code arrival because - * `require` is synchronous (see {@link WebBootGraph.entries}). + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). */ export interface WebBootEntry { /** Entry name == package name. */ @@ -55,7 +54,7 @@ export interface WebBootEntry { url: string /** Bundle content hash (cache-busting consistency anchor). */ rev: string - /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ immediately?: boolean @@ -83,6 +82,8 @@ export interface BootModuleRow { url: string /** Bundle content hash. */ rev: string + /** Injected package rows whose factories arrive before this row materializes. */ + inject: string[] /** Module specifiers this row requests from the module table ([] when the wire omits them). */ external: string[] } @@ -176,6 +177,7 @@ export function parseBootManifest(wire: unknown): BootManifest { id: row.id, url: row.url, rev: row.rev, + inject: inject === undefined ? [] : [...inject], external: external === undefined ? [] : [...external], }) plugins.push({ diff --git a/packages/client/modules/src/client/system.ts b/packages/client/modules/src/client/system.ts index d4cbb5597b..4dd5311378 100644 --- a/packages/client/modules/src/client/system.ts +++ b/packages/client/modules/src/client/system.ts @@ -124,8 +124,12 @@ export class ClientModuleSystem implements ClientModuleLoader { return task } - /** Register each unresolved dynamic request before registering its consumer. */ - private async arriveGraphRow(row: BootModuleRow, open: readonly string[] = []): Promise { + /** Register each injected package and unresolved dynamic request before its consumer. */ + private async arriveGraphRow( + row: BootModuleRow, + open: readonly string[] = [], + visited = new Set(), + ): Promise { const cycleStart = open.indexOf(row.id) if (cycleStart !== -1) { throw new Error( @@ -133,12 +137,18 @@ export class ClientModuleSystem implements ClientModuleLoader { + '(the host must reject this graph before serving it)', ) } + if (visited.has(row.id)) return + visited.add(row.id) const next = [...open, row.id] for (const request of row.external) { const id = stripClientSuffix(request) if (this.seed.has(request) || this.loadCache.has(id)) continue const dependency = this.graphRows.get(id) - if (dependency !== undefined) await this.arriveGraphRow(dependency, next) + if (dependency !== undefined) await this.arriveGraphRow(dependency, next, visited) + } + for (const packageName of row.inject) { + const dependency = this.graphRows.get(packageName) + if (dependency !== undefined) await this.arriveGraphRow(dependency, [], visited) } await this.arrive(row) } diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index 747067ba37..07e83e306b 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -20,7 +20,7 @@ afterEach(() => { }) const row = (id: string, fields: Partial = {}): BootModuleRow => - ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', external: [], ...fields }) + ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', inject: [], external: [], ...fields }) interface Bench { loader: ClientModuleLoader @@ -147,6 +147,22 @@ describe('lazy CJS arrival', () => { expect(exports.react.marker).toBe('react') }) + it('registers injected package factories before materializing a consumer', async () => { + const b = bench([ + row('consumer', { inject: ['provider'] }), + row('provider', { inject: ['consumer'] }), + ], { + consumer: req => ({ provider: req('provider/client') }), + provider: () => ({ marker: 'provider' }), + }) + const exports = await b.loader.import('consumer', '', {}) as { provider: { marker: string } } + expect(b.fetched).toEqual([ + '/plugins/provider/client.js?rev=0', + '/plugins/consumer/client.js?rev=0', + ]) + expect(exports.provider.marker).toBe('provider') + }) + it('concurrent callers share one in-flight arrival and materialize once', async () => { const ran: string[] = [] const url = '/plugins/a/client.js?rev=0' @@ -306,13 +322,13 @@ describe('boot manifest wire', () => { const manifest = parseBootManifest({ rev: 'graph', entries: [ - { id: 'a', url: '/plugins/a/client.js', rev: '1' }, + { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'] }, { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] }, ], }) expect(manifest.modules).toEqual([ - { id: 'a', url: '/plugins/a/client.js', rev: '1', external: [] }, - { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] }, + { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'], external: [] }, + { id: 'b', url: '/plugins/b/client.js', rev: '2', inject: [], external: ['react'] }, ]) })