diff --git a/packages/host/app/components/prerendered-card-search.gts b/packages/host/app/components/prerendered-card-search.gts index efd16ded09..68e504d2ed 100644 --- a/packages/host/app/components/prerendered-card-search.gts +++ b/packages/host/app/components/prerendered-card-search.gts @@ -202,6 +202,54 @@ export default class PrerenderedCardSearch extends Component { + let realmUrl = resolveCardRealmUrl(r.id, realms); + return new PrerenderedCard( + { + url: r.id, + realmUrl, + html: r.attributes?.html, + isError: !!r.attributes?.isError, + }, + undefined, // no modifier during rehydration — DOM already correct + ); + }); + + this._lastSearchResults = { + instances, + meta: json.meta ?? { page: { total: 0 } }, + }; + this._lastSearchQuery = this.args.query ?? null; + this._lastRealms = realms; + break; + } + } + private get cardComponentModifier() { if (isDestroying(this) || isDestroyed(this)) { return undefined; @@ -411,8 +459,9 @@ export default class PrerenderedCardSearch extends Component Boxel - - - +
-
{{content-for "body"}} diff --git a/packages/host/app/initializers/experimental-rehydrate.ts b/packages/host/app/initializers/experimental-rehydrate.ts new file mode 100644 index 0000000000..025dd280e5 --- /dev/null +++ b/packages/host/app/initializers/experimental-rehydrate.ts @@ -0,0 +1,222 @@ +import type Application from '@ember/application'; + +// @ts-expect-error - glimmer internals not typed for direct import +import { clientBuilder, rehydrationBuilder } from '@glimmer/runtime'; +// @ts-expect-error - glimmer internals not typed for direct import +import { ConcreteBounds, NewElementBuilder } from '@glimmer/runtime'; + +declare const FastBoot: unknown; + +// Inlined from @glimmer/node to avoid pulling in a second copy of +// @glimmer/runtime (and its transitive @glimmer/global-context) which +// would cause webpack to bundle two uninitialised copies and break at +// runtime with "scheduleDestroyed is not a function". +const NEEDS_EXTRA_CLOSE = new WeakMap(); + +class SerializeBuilder extends (NewElementBuilder as any) { + serializeBlockDepth = 0; + + __openBlock() { + let { tagName } = this.element; + if (tagName !== 'TITLE' && tagName !== 'SCRIPT' && tagName !== 'STYLE') { + let depth = this.serializeBlockDepth++; + this.__appendComment(`%+b:${depth}%`); + } + super.__openBlock(); + } + + __closeBlock() { + let { tagName } = this.element; + super.__closeBlock(); + if (tagName !== 'TITLE' && tagName !== 'SCRIPT' && tagName !== 'STYLE') { + let depth = --this.serializeBlockDepth; + this.__appendComment(`%-b:${depth}%`); + } + } + + __appendHTML(html: string) { + let { tagName } = this.element; + if (tagName === 'TITLE' || tagName === 'SCRIPT' || tagName === 'STYLE') { + return super.__appendHTML(html); + } + let first = this.__appendComment('%glmr%'); + if (tagName === 'TABLE') { + let openIndex = html.indexOf('<'); + if (openIndex > -1 && html.slice(openIndex + 1, openIndex + 3) === 'tr') { + html = `${html}`; + } + } + if (html === '') { + this.__appendComment('% %'); + } else { + super.__appendHTML(html); + } + let last = this.__appendComment('%glmr%'); + return new (ConcreteBounds as any)(this.element, first, last); + } + + __appendText(string: string) { + let { tagName } = this.element; + let current = ((): any => { + let { element, nextSibling } = this as any; + return nextSibling === null + ? element.lastChild + : nextSibling.previousSibling; + })(); + if (tagName === 'TITLE' || tagName === 'SCRIPT' || tagName === 'STYLE') { + return super.__appendText(string); + } + if (string === '') { + return this.__appendComment('% %'); + } + if (current && current.nodeType === 3) { + this.__appendComment('%|%'); + } + return super.__appendText(string); + } + + closeElement() { + if (NEEDS_EXTRA_CLOSE.has(this.element)) { + NEEDS_EXTRA_CLOSE.delete(this.element); + super.closeElement(); + } + return super.closeElement(); + } + + openElement(tag: string) { + if ( + tag === 'tr' && + this.element.tagName !== 'TBODY' && + this.element.tagName !== 'THEAD' && + this.element.tagName !== 'TFOOT' + ) { + this.openElement('tbody'); + NEEDS_EXTRA_CLOSE.set(this.constructing, true); + this.flushElement(null); + } + return super.openElement(tag); + } + + pushRemoteElement(element: any, cursorId: string, insertBefore: any = null) { + let { dom } = this as any; + let script = dom.createElement('script'); + script.setAttribute('glmr', cursorId); + dom.insertBefore(element, script, insertBefore); + return super.pushRemoteElement(element, cursorId, insertBefore); + } +} + +function serializeBuilder(env: any, cursor: any) { + return SerializeBuilder.forInitialRender(env, cursor); +} + +// Wraps the standard rehydrationBuilder with a fix for Glimmer's handling +// of empty-text markers (). When __appendText("") encounters a +// % % comment, the stock rehydrate builder removes it and recurses via +// this.__appendText(""). The recursive call then hits the next candidate +// (typically a close-block marker ) and triggers clearMismatch, +// causing the entire subtree to be re-rendered. +// +// The fix intercepts this case: when __appendText("") finds a % % comment, +// it replaces the comment with an empty text node in-place (preserving DOM +// position for bounds tracking) and advances the candidate pointer past it. +function fixedRehydrationBuilder(env: any, cursor: any) { + let builder = rehydrationBuilder(env, cursor); + let origAppendText = builder.__appendText.bind(builder); + let origClearMismatch = builder.clearMismatch.bind(builder); + + builder.clearMismatch = function (candidate: any) { + console.warn( + '[rehydration] MISMATCH at', + candidate?.nodeName, + candidate?.nodeType === 8 + ? candidate.nodeValue + : candidate?.nodeType === 3 + ? 'text:' + JSON.stringify(candidate.nodeValue?.slice(0, 80)) + : candidate?.outerHTML?.slice(0, 120), + 'parent:', + candidate?.parentNode?.tagName, + candidate?.parentNode?.id, + ); + return origClearMismatch(candidate); + }; + + builder.__appendText = function (string: string) { + let candidate = this.currentCursor?.candidate; + if ( + string === '' && + candidate && + candidate.nodeType === 8 && + candidate.nodeValue === '% %' + ) { + let textNode = document.createTextNode(''); + let parent = candidate.parentNode!; + let next = candidate.nextSibling; + parent.replaceChild(textNode, candidate); + this.currentCursor.candidate = next; + return textNode; + } + // When __appendText is called with whitespace-only content but the + // candidate is an element node (not a text node or comment), the + // serialized HTML has interstitial whitespace between block markers + // that was consumed during marker processing. Instead of triggering + // clearMismatch (which destroys the remaining subtree), insert a + // whitespace text node before the element — this preserves DOM + // identity for the element and its descendants. + if ( + string.trim() === '' && + candidate && + candidate.nodeType === 1 // Element node + ) { + let textNode = document.createTextNode(string); + candidate.parentNode!.insertBefore(textNode, candidate); + // Don't advance candidate — the element is still next + return textNode; + } + return origAppendText(string); + }; + + return builder; +} + +export function initialize(application: Application): void { + // Don't override in FastBoot (server-side) — let Ember's default serialize mode work + if (typeof FastBoot !== 'undefined') { + return; + } + + application.register('service:-dom-builder', { + create() { + // Allow ?serialize query param to force serialize mode for debugging + if ( + typeof document !== 'undefined' && + window.location?.search?.includes('serialize') + ) { + console.log( + '[ember-host] Boxel render mode override: serialize (via query param)', + ); + return serializeBuilder.bind(null); + } else if ( + typeof document !== 'undefined' && + // @ts-expect-error hmm + globalThis.__boxelRenderMode === 'rehydrate' + ) { + console.log('[ember-host] Boxel render mode override: rehydrate!'); + return fixedRehydrationBuilder.bind(null); + } else if ( + typeof document !== 'undefined' && + // @ts-expect-error what to do + globalThis.__boxelRenderMode === 'serialize' + ) { + console.log('[ember-host] Boxel render mode override: serialize'); + return serializeBuilder.bind(null); + } else { + return clientBuilder.bind(null); + } + }, + }); +} + +export default { + initialize, +}; diff --git a/packages/host/app/instance-initializers/populate-shoebox.ts b/packages/host/app/instance-initializers/populate-shoebox.ts new file mode 100644 index 0000000000..e6d3fe912d --- /dev/null +++ b/packages/host/app/instance-initializers/populate-shoebox.ts @@ -0,0 +1,68 @@ +import type ApplicationInstance from '@ember/application/instance'; + +// This instance initializer pre-loads card instances from shoebox data into the +// store before routing starts. This ensures that store.peek() returns card +// instances during the first (rehydration) render so the template output +// matches the prerendered DOM and the rehydration builder can adopt it without +// triggering clearMismatch. +// +// Timing: instance initializers run BEFORE router.startRouting() inside +// ApplicationInstance._bootSync(). We monkey-patch startRouting to delay it +// until the preload promise settles, then call the original. + +export function initialize(appInstance: ApplicationInstance): void { + let shoeboxData = (globalThis as any).__boxelShoeboxData; + if (!shoeboxData || (globalThis as any).__boxelRenderMode !== 'rehydrate') { + return; + } + + let store = appInstance.lookup('service:store') as any; + // Only preload card URLs — skip internal keys like __search:* + let shoeboxUrls = Object.keys(shoeboxData).filter( + (key) => !key.startsWith('__'), + ); + + if (shoeboxUrls.length === 0) { + return; + } + + // Routes use URLs like /minicatalog/ but the shoebox keys are canonical + // (/minicatalog/index). We must preload using the route-format URL so the + // store registers the card under the key that peek() will be called with. + // The fetch interceptor handles the /index fallback. + let routeUrls = shoeboxUrls.map((url) => + url.endsWith('/index') ? url.slice(0, -'index'.length) : url, + ); + let allUrls = [...new Set([...routeUrls, ...shoeboxUrls])]; + + let preloadPromise = (async () => { + await store.ensureSetupComplete(); + await Promise.all( + allUrls.map((url: string) => + store.get(url).catch((e: any) => { + console.warn('[shoebox] Failed to preload card:', url, e); + }), + ), + ); + })(); + + // Delay routing until the store is populated. The router singleton is + // already registered; we just patch its startRouting method. + let router = appInstance.lookup('router:main') as any; + let originalStartRouting = router.startRouting.bind(router); + router.startRouting = function () { + preloadPromise + .catch((e: any) => { + console.error('[shoebox] Pre-load failed, starting routing anyway:', e); + }) + .then(() => { + // Restore original method so future calls are not affected + router.startRouting = originalStartRouting; + originalStartRouting(); + }); + }; +} + +export default { + initialize, +}; diff --git a/packages/host/app/lib/html-component.ts b/packages/host/app/lib/html-component.ts index 9dce0f50d3..83110d4cbe 100644 --- a/packages/host/app/lib/html-component.ts +++ b/packages/host/app/lib/html-component.ts @@ -37,6 +37,11 @@ export function htmlComponent( html: string, extraAttributes: Record = {}, ): HTMLComponent { + // Strip Glimmer serialization block markers (, , + // ) that the SerializeBuilder injects during prerendering. + // These comments are consumed by the RehydrateBuilder on the client but + // must not be present when parsing isolated card HTML for htmlComponent. + html = html.replace(//g, ''); let testContainer = document.createElement('div'); testContainer.innerHTML = html; if ( diff --git a/packages/host/app/routes/index.gts b/packages/host/app/routes/index.gts index ff05fa33e3..9387668d22 100644 --- a/packages/host/app/routes/index.gts +++ b/packages/host/app/routes/index.gts @@ -57,6 +57,7 @@ export default class Card extends Route { didMatrixServiceStart = false; initialLoading = true; + rehydrated = false; @action loading(transition: Transition) { @@ -65,6 +66,19 @@ export default class Card extends Route { this.initialLoading = false; }); + if (this.rehydrated) { + return false; // Don't show loading spinner over rehydrated content + } + + // Don't show loading template during Puppeteer prerendering (serialize mode). + // The loading substate creates a different outlet block structure than a + // synchronous render, which causes rehydration mismatches because the + // serialized HTML has fewer block markers than the rehydration template expects. + // @ts-expect-error __boxelForceHostMode set by Puppeteer + if (globalThis.__boxelForceHostMode) { + return false; + } + return this.initialLoading; } @@ -79,6 +93,32 @@ export default class Card extends Route { path: string; operatorModeState: string; }) { + // @ts-expect-error render mode flag set by realm server + if (!this.rehydrated && globalThis.__boxelRenderMode === 'rehydrate') { + // Return synchronously so the index template is part of the initial + // render — this lets the rehydration builder adopt the prerendered DOM. + // Service initialization runs asynchronously after the first render. + // NOTE: Do NOT delete __boxelRenderMode here — the DOM builder service + // reads it lazily during the first render (after this hook returns). + this.rehydrated = true; + this.initialLoading = false; + + // For host mode, set up state synchronously from route params + // so the template renders the correct card during rehydration. + if (this.hostModeService.isActive) { + let normalizedPath = params.path ?? ''; + let cardUrl = `${this.hostModeService.hostModeOrigin}/${normalizedPath}`; + this.hostModeStateService.restore({ + primaryCardId: cardUrl, + routePath: normalizedPath, + serializedStack: undefined, + }); + } + + this.deferredInit(params); + return; + } + if (this.hostModeService.isActive) { let normalizedPath = params.path ?? ''; let cardUrl = `${this.hostModeService.hostModeOrigin}/${normalizedPath}`; @@ -172,12 +212,64 @@ export default class Card extends Route { } } + async deferredInit(params: { + operatorModeState: string; + cardPath?: string; + authRedirect?: string; + path: string; + }) { + try { + if (this.hostModeService.isActive) { + return; + } + + if (!this.didMatrixServiceStart) { + await this.matrixService.ready; + await this.matrixService.start(); + this.didMatrixServiceStart = true; + } + + if (!this.matrixService.isLoggedIn) { + return; + } + + if (params.authRedirect) { + window.location.href = params.authRedirect; + return; + } + + if (!isTesting()) { + await this.billingService.initializeSubscriptionData(); + } + this.matrixService.loginToRealms(); + + let { operatorModeState } = params; + let operatorModeStateObject = operatorModeState + ? JSON.parse(operatorModeState) + : undefined; + if (operatorModeStateObject) { + await this.operatorModeStateService.restore( + operatorModeStateObject || { stacks: [] }, + ); + } + } catch (e) { + console.error('[rehydration] Deferred init failed:', e); + } + } + async afterModel( model: ReturnType, transition: Transition, ) { await super.afterModel(model, transition); + if (this.rehydrated) { + // Host-mode state was set up synchronously in model(). + // Skip async work so the route activates immediately and + // the rehydration builder can adopt the prerendered DOM. + return; + } + if (!this.hostModeService.isActive) { return; } diff --git a/packages/host/app/services/host-mode-service.ts b/packages/host/app/services/host-mode-service.ts index f35934feef..e67a259fde 100644 --- a/packages/host/app/services/host-mode-service.ts +++ b/packages/host/app/services/host-mode-service.ts @@ -40,6 +40,9 @@ export default class HostModeService extends Service { @tracked headTemplateContainsTitle = false; get isActive() { + if ((globalThis as any).__boxelForceHostMode) { + return true; + } if (this.simulatingHostMode) { return true; } @@ -75,6 +78,10 @@ export default class HostModeService extends Service { } get hostModeOrigin() { + let forced = (globalThis as any).__boxelForceHostMode; + if (forced?.origin) { + return forced.origin; + } if (this.simulatingHostMode) { return new URLSearchParams(window.location.search).get('hostModeOrigin'); } diff --git a/packages/host/app/templates/index.gts b/packages/host/app/templates/index.gts index fa5d3f853a..095b3fda26 100644 --- a/packages/host/app/templates/index.gts +++ b/packages/host/app/templates/index.gts @@ -199,24 +199,6 @@ export class IndexComponent extends Component }; }); - // TODO: remove in CS-9977, with rehydration - removeIsolatedMarkup = modifier(() => { - if (typeof document === 'undefined') { - return; - } - let start = document.getElementById('boxel-isolated-start'); - let end = document.getElementById('boxel-isolated-end'); - if (!start || !end) { - return; - } - let node = start.nextSibling; - while (node && node !== end) { - let next = node.nextSibling; - node.parentNode?.removeChild(node); - node = next; - } - }); -