diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index 9aee3b0b59..c0aa6ad90f 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -45,6 +45,13 @@ export class AppWebEntry { */ async run(): Promise { try { + // Boot-readiness gate: whichever bootstrap applies the injection table + // settles this deferred once every row has taken effect — the served + // index resolves it in the rendered tail, so the await returns on the + // next microtask; an asynchronous bootstrap resolves it after its last + // row, or rejects it into the failure rendering below. An absent global + // means no bootstrap owns the document and there is nothing to wait for. + await (globalThis as { __DSH_BOOT_READY__?: { promise: Promise } }).__DSH_BOOT_READY__?.promise const win = globalThis as DshWindow const moduleLoader = win.__ModuleLoader__ if (moduleLoader === undefined) { diff --git a/packages/host/webserver/src/injections.ts b/packages/host/webserver/src/injections.ts index dc38213e85..7a61ae0510 100644 --- a/packages/host/webserver/src/injections.ts +++ b/packages/host/webserver/src/injections.ts @@ -71,10 +71,20 @@ function splice(html: string, at: number, markup: string): string { return `${html.slice(0, at)}${markup}${html.slice(at)}` } +/** + * Tail script settling the boot-readiness deferred (`__DSH_BOOT_READY__`): + * the client entry awaits its `.promise` before reading any injected state. + * Whichever side runs first creates the deferred (`??=`), so a bootstrap that + * applies the table asynchronously installs it ahead of the entry module and + * settles it after the last row; the served form below creates and resolves + * it in one statement, because every row is already in the document text. + */ +const READY_MARKUP = '' + /** * Render rows into an index.html body: head rows immediately after the * opening head tag, body rows immediately after the opening body tag, each - * group in table order. + * group in table order, and the boot-readiness tail after the last body row. * @param html - the raw index.html body. * @param rows - the collected injection table. * @returns the html with every row rendered. @@ -87,6 +97,7 @@ export function renderIndexInjections(html: string, rows: readonly IndexInjectio if (rendered.placement === 'head') head += rendered.markup else body += rendered.markup } + body += READY_MARKUP let out = html if (head !== '') { const open = /]*)?>/i.exec(out) diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index ffe5b4648d..e8fa315ecc 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -241,11 +241,13 @@ describe('real Loader composition', () => { expect(server.renderIndex('')).toContain('window.__Q__=2') untap() - // Tag-less fragments: head rows prepend, body rows append. + // Tag-less fragments: head rows prepend, body rows append, and the + // boot-readiness tail lands after the last body row. expect(renderIndexInjections('
x
', [ { kind: 'script', placement: 'head', text: 'H' }, { kind: 'script', placement: 'body', text: 'B' }, - ])).toBe('
x
') + ])).toBe('
x
' + + '') }) it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {