diff --git a/packages/base/default-templates/markdown.gts b/packages/base/default-templates/markdown.gts index 90fb3610b95..64ede21f5b7 100644 --- a/packages/base/default-templates/markdown.gts +++ b/packages/base/default-templates/markdown.gts @@ -10,6 +10,7 @@ import LinkOffIcon from '@cardstack/boxel-icons/link-off'; import { bfmBlockFormatAndSize, + buildWaiter, cardTypeName, extractMermaidBlocks, processKatexPlaceholders, @@ -42,6 +43,11 @@ function wrapTablesHtml(html: string | null | undefined): string { return doc.body.innerHTML; } +// Lets `settled()` wait for the async markdown rendering work (Mermaid/KaTeX +// lazy-loading and the deferred card-slot collection) that is kicked off by +// modifiers and ember-concurrency tasks after the initial render settles. +const markdownRenderingWaiter = buildWaiter('markdown-rendering'); + type CardSlotFormat = 'atom' | 'embedded' | 'fitted' | 'isolated'; type SlotState = 'resolved' | 'loading' | 'unresolved'; @@ -179,6 +185,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ let baseUrl = this.args.cardReferenceBaseUrl; let virtualNetwork = this.args.cardReferenceVirtualNetwork; let pendingUpdate = false; + let pendingToken: unknown = undefined; // On the very first modifier run linkedCards is likely still loading // (empty []) so we skip unresolved Pills to avoid flashing them for // refs that will soon resolve. On subsequent runs (linkedCards changed) @@ -280,22 +287,28 @@ export default class MarkDownTemplate extends GlimmerComponent<{ // re-render → observer fires again. let updateSlots = () => { pendingUpdate = false; - let nextSlots = collectSlots(); - let didChange = - nextSlots.length !== this.renderSlots.length || - nextSlots.some((slot, index) => { - let current = this.renderSlots[index]; - if (!current || current.element !== slot.element) return true; - if (current.kind !== slot.kind) return true; - if (current.state !== slot.state) return true; - if (current.format !== slot.format) return true; - if (current.card !== slot.card) return true; - if (current.url !== slot.url) return true; - return String(current.style ?? '') !== String(slot.style ?? ''); - }); - - if (didChange) { - this.renderSlots = nextSlots; + let token = pendingToken; + pendingToken = undefined; + try { + let nextSlots = collectSlots(); + let didChange = + nextSlots.length !== this.renderSlots.length || + nextSlots.some((slot, index) => { + let current = this.renderSlots[index]; + if (!current || current.element !== slot.element) return true; + if (current.kind !== slot.kind) return true; + if (current.state !== slot.state) return true; + if (current.format !== slot.format) return true; + if (current.card !== slot.card) return true; + if (current.url !== slot.url) return true; + return String(current.style ?? '') !== String(slot.style ?? ''); + }); + + if (didChange) { + this.renderSlots = nextSlots; + } + } finally { + markdownRenderingWaiter.endAsync(token); } }; @@ -304,15 +317,26 @@ export default class MarkDownTemplate extends GlimmerComponent<{ return; } pendingUpdate = true; + pendingToken = markdownRenderingWaiter.beginAsync(); scheduleOnce('afterRender', this, updateSlots); }; scheduleUpdate(); + // End any in-flight waiter token on teardown so a destroyed modifier + // (e.g. the scheduled update never flushed) cannot leave `settled()` + // hanging. `updateSlots` clears `pendingToken` first, so this only fires + // for a still-pending update. + let endPendingToken = () => { + let token = pendingToken; + pendingToken = undefined; + markdownRenderingWaiter.endAsync(token); + }; + // MutationObserver re-collects slots when the DOM is reconstructed // (e.g. after browser back-navigation rebuilds the element's children). if (typeof MutationObserver === 'undefined') { - return; + return endPendingToken; } let observer = new MutationObserver(scheduleUpdate); @@ -321,7 +345,10 @@ export default class MarkDownTemplate extends GlimmerComponent<{ subtree: true, }); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + endPendingToken(); + }; }, ); @@ -339,11 +366,16 @@ export default class MarkDownTemplate extends GlimmerComponent<{ } _loadKatexTask = task({ drop: true }, async () => { - let loadKatex = (globalThis as any).__loadKatex; - if (typeof loadKatex !== 'function') { - return; + let token = markdownRenderingWaiter.beginAsync(); + try { + let loadKatex = (globalThis as any).__loadKatex; + if (typeof loadKatex !== 'function') { + return; + } + this._katex = await loadKatex(); + } finally { + markdownRenderingWaiter.endAsync(token); } - this._katex = await loadKatex(); }); // ── Mermaid lazy loading + pre-rendering ── @@ -372,27 +404,32 @@ export default class MarkDownTemplate extends GlimmerComponent<{ return; } - let mermaid = await loadMermaid(); - mermaid.initialize({ - startOnLoad: false, - securityLevel: 'strict', - theme: 'default', - }); + let token = markdownRenderingWaiter.beginAsync(); + try { + let mermaid = await loadMermaid(); + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'default', + }); - let svgs = new Map(); - for (let block of blocks) { - try { - let { svg } = await mermaid.render( - `mermaid-${++this._mermaidIdCounter}`, - block, - ); - svgs.set(block, svg); - } catch { - // skip failed blocks + let svgs = new Map(); + for (let block of blocks) { + try { + let { svg } = await mermaid.render( + `mermaid-${++this._mermaidIdCounter}`, + block, + ); + svgs.set(block, svg); + } catch { + // skip failed blocks + } } - } - this._mermaidSvgs = svgs; + this._mermaidSvgs = svgs; + } finally { + markdownRenderingWaiter.endAsync(token); + } }); getCardComponent = (card: BaseDef) => getComponent(card); diff --git a/packages/base/flac-audio-def.gts b/packages/base/flac-audio-def.gts index dab2fa15ec0..524ea293678 100644 --- a/packages/base/flac-audio-def.gts +++ b/packages/base/flac-audio-def.gts @@ -1,7 +1,7 @@ import { readFirstBytes } from '@cardstack/runtime-common'; import FileAudioIcon from '@cardstack/boxel-icons/file-audio'; import AudioDef from './audio-file-def'; -import { type ByteStream, type SerializedFile } from './file-api'; +import type { ByteStream, SerializedFile } from './file-api'; import { extractFlacDuration } from './flac-meta-extractor'; // "fLaC" marker (4) + STREAMINFO block header (4) + STREAMINFO data (34) = 42. diff --git a/packages/base/m4a-audio-def.gts b/packages/base/m4a-audio-def.gts index 9ce687d5173..c8010ff8db7 100644 --- a/packages/base/m4a-audio-def.gts +++ b/packages/base/m4a-audio-def.gts @@ -1,6 +1,6 @@ import FileAudioIcon from '@cardstack/boxel-icons/file-audio'; import AudioDef from './audio-file-def'; -import { type ByteStream, type SerializedFile } from './file-api'; +import type { ByteStream, SerializedFile } from './file-api'; import { extractM4aDurationFromStream } from './m4a-meta-extractor'; export class M4aDef extends AudioDef { diff --git a/packages/base/m4a-meta-extractor.ts b/packages/base/m4a-meta-extractor.ts index 1e26c2678cd..331a9df9de5 100644 --- a/packages/base/m4a-meta-extractor.ts +++ b/packages/base/m4a-meta-extractor.ts @@ -121,9 +121,7 @@ function parseMvhd( if (version === 0) { // creation(4) + modification(4) + timescale(4) + duration(4) if (cursor + 16 > mvhd.payloadEnd) { - throw new FileContentMismatchError( - 'MP4 mvhd (v0) box is truncated', - ); + throw new FileContentMismatchError('MP4 mvhd (v0) box is truncated'); } let timescale = view.getUint32(cursor + 8); let duration = view.getUint32(cursor + 12); @@ -133,9 +131,7 @@ function parseMvhd( if (version === 1) { // creation(8) + modification(8) + timescale(4) + duration(8) if (cursor + 28 > mvhd.payloadEnd) { - throw new FileContentMismatchError( - 'MP4 mvhd (v1) box is truncated', - ); + throw new FileContentMismatchError('MP4 mvhd (v1) box is truncated'); } let timescale = view.getUint32(cursor + 16); let durHi = view.getUint32(cursor + 20); @@ -168,9 +164,7 @@ export function extractM4aDuration(bytes: Uint8Array): { duration: number } { let moov = findChildBox(bytes, view, 0, bytes.length, MOOV); if (!moov) { - throw new FileContentMismatchError( - 'MP4 file does not contain a moov box', - ); + throw new FileContentMismatchError('MP4 file does not contain a moov box'); } return durationFromMoov(bytes, view, moov); } @@ -184,18 +178,20 @@ function durationFromMoov( view: DataView, moov: BoxLocation, ): { duration: number } { - let mvhd = findChildBox(bytes, view, moov.payloadOffset, moov.payloadEnd, MVHD); + let mvhd = findChildBox( + bytes, + view, + moov.payloadOffset, + moov.payloadEnd, + MVHD, + ); if (!mvhd) { - throw new FileContentMismatchError( - 'MP4 file does not contain a mvhd box', - ); + throw new FileContentMismatchError('MP4 file does not contain a mvhd box'); } let { timescale, duration } = parseMvhd(bytes, view, mvhd); if (timescale === 0) { - throw new FileContentMismatchError( - 'MP4 mvhd reports a zero timescale', - ); + throw new FileContentMismatchError('MP4 mvhd reports a zero timescale'); } return { duration: duration / timescale }; } @@ -415,9 +411,7 @@ export async function extractM4aDurationFromStream( break; } } - throw new FileContentMismatchError( - 'MP4 file does not contain a moov box', - ); + throw new FileContentMismatchError('MP4 file does not contain a moov box'); } finally { await reader.cancel(); } diff --git a/packages/base/markdown-helpers.ts b/packages/base/markdown-helpers.ts index 01e15854f96..f1c63147345 100644 --- a/packages/base/markdown-helpers.ts +++ b/packages/base/markdown-helpers.ts @@ -272,14 +272,10 @@ export function markdownAudio( } catch { encodedHref = url; } - let attrSafeHref = encodedHref - .replace(/&/g, '&') - .replace(/"/g, '"'); + let attrSafeHref = encodedHref.replace(/&/g, '&').replace(/"/g, '"'); let attrSafeName = (name ?? '') .replace(/&/g, '&') .replace(/"/g, '"'); - let ariaLabel = attrSafeName - ? ` aria-label="${attrSafeName}"` - : ''; + let ariaLabel = attrSafeName ? ` aria-label="${attrSafeName}"` : ''; return ``; } diff --git a/packages/base/mp3-audio-def.gts b/packages/base/mp3-audio-def.gts index 4d42846b104..6fc6138cf0e 100644 --- a/packages/base/mp3-audio-def.gts +++ b/packages/base/mp3-audio-def.gts @@ -1,7 +1,7 @@ import { readFirstBytes } from '@cardstack/runtime-common'; import FileAudioIcon from '@cardstack/boxel-icons/file-audio'; import AudioDef from './audio-file-def'; -import { type ByteStream, type SerializedFile } from './file-api'; +import type { ByteStream, SerializedFile } from './file-api'; import { extractMp3Duration } from './mp3-meta-extractor'; // ID3v2 tags can be large (embedded artwork). 1 MB covers virtually all diff --git a/packages/base/ogg-audio-def.gts b/packages/base/ogg-audio-def.gts index 804c8386cbe..11ad9750ceb 100644 --- a/packages/base/ogg-audio-def.gts +++ b/packages/base/ogg-audio-def.gts @@ -1,6 +1,6 @@ import FileAudioIcon from '@cardstack/boxel-icons/file-audio'; import AudioDef from './audio-file-def'; -import { type ByteStream, type SerializedFile } from './file-api'; +import type { ByteStream, SerializedFile } from './file-api'; import { extractOggDurationFromStream } from './ogg-meta-extractor'; export class OggDef extends AudioDef { diff --git a/packages/base/ogg-meta-extractor.ts b/packages/base/ogg-meta-extractor.ts index adb4918764d..cd889679041 100644 --- a/packages/base/ogg-meta-extractor.ts +++ b/packages/base/ogg-meta-extractor.ts @@ -12,7 +12,14 @@ const GRANULE_POSITION_OFFSET = 6; // Codec identification magics on the first page's data packet const VORBIS_ID_MAGIC = [0x01, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73]; // "\x01vorbis" const OPUS_ID_MAGIC = [ - 0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead" + 0x4f, + 0x70, + 0x75, + 0x73, + 0x48, + 0x65, + 0x61, + 0x64, // "OpusHead" ]; // Opus always outputs at 48 kHz, regardless of the encoder input sample rate @@ -34,10 +41,7 @@ function matchBytes( return true; } -function readUint64LEAsNumber( - view: DataView, - offset: number, -): number { +function readUint64LEAsNumber(view: DataView, offset: number): number { let low = view.getUint32(offset, true); let high = view.getUint32(offset + 4, true); // Granule positions for any plausible audio length fit in a JS number. diff --git a/packages/base/wav-audio-def.gts b/packages/base/wav-audio-def.gts index af202280340..5871f2f0386 100644 --- a/packages/base/wav-audio-def.gts +++ b/packages/base/wav-audio-def.gts @@ -1,7 +1,7 @@ import { readFirstBytes } from '@cardstack/runtime-common'; import FileAudioIcon from '@cardstack/boxel-icons/file-audio'; import AudioDef from './audio-file-def'; -import { type ByteStream, type SerializedFile } from './file-api'; +import type { ByteStream, SerializedFile } from './file-api'; import { extractWavDuration } from './wav-meta-extractor'; // A WAVE file's `fmt ` and `data` chunk headers normally sit within the first diff --git a/packages/base/wav-meta-extractor.ts b/packages/base/wav-meta-extractor.ts index d0069ced0c5..876946f9ca5 100644 --- a/packages/base/wav-meta-extractor.ts +++ b/packages/base/wav-meta-extractor.ts @@ -45,9 +45,7 @@ function validateWavSignature(bytes: Uint8Array): void { ); } if (!matchTag(bytes, 8, WAVE)) { - throw new FileContentMismatchError( - 'File is not a WAVE RIFF container', - ); + throw new FileContentMismatchError('File is not a WAVE RIFF container'); } } @@ -81,9 +79,7 @@ export function extractWavDuration(bytes: Uint8Array): { duration: number } { let advance = CHUNK_HEADER_BYTES + chunkSize + (chunkSize & 1); if (advance <= CHUNK_HEADER_BYTES) { // Malformed (size makes us not advance) — bail rather than loop. - throw new FileContentMismatchError( - 'WAV file contains a malformed chunk', - ); + throw new FileContentMismatchError('WAV file contains a malformed chunk'); } offset += advance; } @@ -94,9 +90,7 @@ export function extractWavDuration(bytes: Uint8Array): { duration: number } { ); } if (dataSize === undefined) { - throw new FileContentMismatchError( - 'WAV file is missing a data chunk', - ); + throw new FileContentMismatchError('WAV file is missing a data chunk'); } return { duration: dataSize / byteRate }; diff --git a/packages/host/tests/acceptance/markdown-file-def-test.gts b/packages/host/tests/acceptance/markdown-file-def-test.gts index b2f312cd184..4e91db69b03 100644 --- a/packages/host/tests/acceptance/markdown-file-def-test.gts +++ b/packages/host/tests/acceptance/markdown-file-def-test.gts @@ -1,10 +1,10 @@ import { click, fillIn, + settled, triggerEvent, triggerKeyEvent, visit, - waitFor, waitUntil, } from '@ember/test-helpers'; @@ -336,7 +336,7 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}bfm-test.md`, }); - await waitFor('[data-test-pet-atom]', { timeout: 10000 }); + await settled(); assert .dom('[data-test-pet-atom]') @@ -388,12 +388,7 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}bfm-fallback.md`, }); - await waitUntil( - () => - document.querySelector('[data-test-markdown-bfm-unresolved-inline]') !== - null, - { timeout: 10000 }, - ); + await settled(); assert .dom('[data-test-markdown-bfm-unresolved-inline]') @@ -427,9 +422,7 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}bfm-test.md`, }); - await waitFor('[data-test-markdown-bfm-inline-card]', { timeout: 10000 }); - await waitFor('[data-test-markdown-bfm-block-card]', { timeout: 10000 }); - await waitFor('[data-test-card-url-bar-input]'); + await settled(); let urlInput = document.querySelector( '[data-test-card-url-bar-input]', @@ -452,16 +445,17 @@ module('Acceptance | markdown BFM card references', function (hooks) { await click('[data-test-markdown-bfm-inline-card]'); - await waitUntil(() => { - let currentValue = - ( - document.querySelector( - '[data-test-card-url-bar-input]', - ) as HTMLInputElement | null - )?.value ?? ''; - return currentValue !== startingValue; - }); - + let navigatedValue = + ( + document.querySelector( + '[data-test-card-url-bar-input]', + ) as HTMLInputElement | null + )?.value ?? ''; + assert.notStrictEqual( + navigatedValue, + startingValue, + 'clicking the inline card reference navigates the URL bar', + ); assert .dom('[data-test-card-url-bar-input]') .hasValue(`${testRealmURL}Pet/mango.json`); @@ -473,44 +467,29 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}bfm-test.md`, }); - await waitFor('[data-test-pet-atom]', { timeout: 10000 }); - await waitFor('[data-test-pet-embedded]', { timeout: 10000 }); - await waitFor('[data-test-card-url-bar-input]'); + await settled(); - // Wait for the overlay click handler to be bound (cursor: pointer is set - // by the Overlays component). CI runners under load need longer for the - // render cycles before the handler binds. - await waitUntil( - () => { - let el = document.querySelector( + // The overlay click handler (cursor: pointer set by the Overlays component) + // is bound once the card-reference slots resolve, which the markdown + // rendering waiter now tracks — so `settled()` above is sufficient. + assert.strictEqual( + ( + document.querySelector( '[data-test-markdown-bfm-inline-card]', - ) as HTMLElement | null; - return el?.style.cursor === 'pointer'; - }, - { - timeout: 15000, - timeoutMessage: 'overlay click handler (cursor:pointer) was not bound', - }, + ) as HTMLElement | null + )?.style.cursor, + 'pointer', + 'overlay click handler (cursor:pointer) is bound', ); await click('[data-test-markdown-bfm-inline-card]'); - await waitUntil( - () => { - let currentValue = - ( - document.querySelector( - '[data-test-card-url-bar-input]', - ) as HTMLInputElement | null - )?.value ?? ''; - return currentValue === `${testRealmURL}Pet/mango.json`; - }, - { - timeout: 15000, - timeoutMessage: - 'URL bar did not navigate to Pet/mango.json after click', - }, - ); + assert + .dom('[data-test-card-url-bar-input]') + .hasValue( + `${testRealmURL}Pet/mango.json`, + 'URL bar navigates to Pet/mango.json after click', + ); // Navigate back to the markdown file via the URL bar (code-mode navigation // uses replaceState, so history.back() has no entry to return to). @@ -524,9 +503,6 @@ module('Acceptance | markdown BFM card references', function (hooks) { 'Enter', ); - await waitFor('[data-test-pet-atom]', { timeout: 10000 }); - await waitFor('[data-test-pet-embedded]', { timeout: 10000 }); - assert .dom('[data-test-pet-atom]') .exists( @@ -552,8 +528,7 @@ module('Acceptance | markdown BFM card references', function (hooks) { ], }); - await waitFor('[data-test-markdown-bfm-inline-card]', { timeout: 10000 }); - await waitFor('[data-test-markdown-bfm-block-card]', { timeout: 10000 }); + await settled(); await triggerEvent('[data-test-markdown-bfm-inline-card]', 'mouseenter'); assert @@ -584,11 +559,7 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}mermaid-test.md`, }); - // Wait for mermaid to lazy-load and render the diagram into an SVG. - await waitUntil(() => document.querySelector('pre.mermaid svg') !== null, { - timeout: 15000, - timeoutMessage: 'Mermaid diagram was not rendered as SVG within timeout', - }); + await settled(); assert .dom('pre.mermaid svg') @@ -608,14 +579,7 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}math-test.md`, }); - // Wait for KaTeX to lazy-load and render the math expressions. - await waitUntil( - () => document.querySelector('.math-placeholder .katex') !== null, - { - timeout: 15000, - timeoutMessage: 'KaTeX did not render math expressions within timeout', - }, - ); + await settled(); assert .dom('.math-placeholder .katex') diff --git a/packages/observability/grafanactl/resources/dashboards/boxel-status/service-prerender-manager.json b/packages/observability/grafanactl/resources/dashboards/boxel-status/service-prerender-manager.json index 96fdc322651..732a9086e37 100644 --- a/packages/observability/grafanactl/resources/dashboards/boxel-status/service-prerender-manager.json +++ b/packages/observability/grafanactl/resources/dashboards/boxel-status/service-prerender-manager.json @@ -34,9 +34,7 @@ "icon": "external link", "includeVars": false, "keepTime": true, - "tags": [ - "service:prerender-server" - ], + "tags": ["service:prerender-server"], "title": "Prerender Server (pool members)", "type": "dashboards" }, @@ -45,9 +43,7 @@ "icon": "external link", "includeVars": false, "keepTime": true, - "tags": [ - "workflow:indexing" - ], + "tags": ["workflow:indexing"], "title": "Indexing", "type": "dashboards" }, @@ -56,9 +52,7 @@ "icon": "external link", "includeVars": false, "keepTime": true, - "tags": [ - "forensics" - ], + "tags": ["forensics"], "title": "Logs (all)", "type": "dashboards" } @@ -128,9 +122,7 @@ "justifyMode": "center", "orientation": "horizontal", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "e1", "values": false }, @@ -260,9 +252,7 @@ "justifyMode": "center", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -329,9 +319,7 @@ "justifyMode": "center", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -398,9 +386,7 @@ "justifyMode": "center", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -547,9 +533,7 @@ "maxVizHeight": 300, "sizing": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false } @@ -689,9 +673,7 @@ "id": 21, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true @@ -868,10 +850,7 @@ "id": 22, "options": { "legend": { - "calcs": [ - "mean", - "max" - ], + "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true @@ -1067,10 +1046,7 @@ "id": 23, "options": { "legend": { - "calcs": [ - "mean", - "max" - ], + "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true @@ -1233,9 +1209,7 @@ "id": 24, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true @@ -1427,9 +1401,7 @@ "id": 30, "options": { "legend": { - "calcs": [ - "sum" - ], + "calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true @@ -1618,10 +1590,7 @@ "id": 31, "options": { "legend": { - "calcs": [ - "mean", - "max" - ], + "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true @@ -1739,10 +1708,7 @@ "id": 40, "options": { "legend": { - "calcs": [ - "mean", - "max" - ], + "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true @@ -1921,9 +1887,7 @@ "id": 41, "options": { "legend": { - "calcs": [ - "sum" - ], + "calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true @@ -2102,9 +2066,7 @@ "id": 50, "options": { "legend": { - "calcs": [ - "sum" - ], + "calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true @@ -2283,9 +2245,7 @@ "id": 51, "options": { "legend": { - "calcs": [ - "sum" - ], + "calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true @@ -2473,9 +2433,7 @@ "countRows": false, "enablePagination": true, "fields": "", - "reducer": [ - "sum" - ], + "reducer": ["sum"], "show": false }, "showHeader": true, @@ -2625,9 +2583,7 @@ ], "refresh": "30s", "schemaVersion": 42, - "tags": [ - "service:prerender-manager" - ], + "tags": ["service:prerender-manager"], "templating": { "list": [ { diff --git a/packages/runtime-common/fetcher.ts b/packages/runtime-common/fetcher.ts index 666c83701a7..efc49231fc7 100644 --- a/packages/runtime-common/fetcher.ts +++ b/packages/runtime-common/fetcher.ts @@ -1,5 +1,8 @@ +import { buildWaiter, waitForPromise } from './test-waiters'; import type { VirtualNetwork } from './virtual-network'; +const fetcherWaiter = buildWaiter('fetcher'); + export type FetcherMiddlewareHandler = ( req: Request, next: (onwardReq: Request) => Promise, @@ -42,11 +45,11 @@ export function fetcher( ? urlOrRequest : new Request(urlOrRequest, init); - let token = beginAsync(); + let token = fetcherWaiter.beginAsync(); try { return responseWithWaiters(await buildNext(middlewareStack)(request)); } finally { - endAsync(token); + fetcherWaiter.endAsync(token); } }; return instance; @@ -105,29 +108,6 @@ function responseWithWaiters(response: Response): Response { }); } -let waitForPromise: Waiters['waitForPromise'] = (p) => { - return p; -}; - -let beginAsync = (): unknown => { - return 'token'; -}; - -let endAsync = (_token: unknown): void => { - // pass -}; - -export interface Waiters { - buildWaiter(label: string): { - beginAsync(): unknown; - endAsync(token: unknown): void; - }; - waitForPromise(promise: Promise, label?: string): Promise; -} - -export function useTestWaiters(w: Waiters) { - ({ waitForPromise } = w); - let waiter = w.buildWaiter('fetcher'); - beginAsync = waiter.beginAsync.bind(waiter); - endAsync = waiter.endAsync.bind(waiter); -} +// Re-exported for backwards compatibility – these were previously defined here +// and may be consumed via deep imports from this module. +export { useTestWaiters, type Waiters } from './test-waiters'; diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index cb7912927e0..23b27c8ce4d 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -696,6 +696,7 @@ export * from './stream'; export * from './realm'; export * from './realm-index-updater'; export * from './fetcher'; +export * from './test-waiters'; export * from './scoped-css'; export * from './html-utils'; export * from './utils'; diff --git a/packages/runtime-common/test-waiters.ts b/packages/runtime-common/test-waiters.ts new file mode 100644 index 00000000000..09728bade9f --- /dev/null +++ b/packages/runtime-common/test-waiters.ts @@ -0,0 +1,59 @@ +// Test-waiter plumbing for code that runs inside the card sandbox (e.g. base +// card defs and shared runtime-common utilities), which cannot import +// `@ember/test-waiters` directly. The host test setup injects the real +// implementation once via `useTestWaiters`; outside of tests (and before +// injection) everything here is a no-op so production code is unaffected. + +export interface Waiters { + buildWaiter(label: string): { + beginAsync(): unknown; + endAsync(token: unknown): void; + }; + waitForPromise(promise: Promise, label?: string): Promise; +} + +let injectedWaiters: Waiters | undefined; + +export function useTestWaiters(w: Waiters) { + injectedWaiters = w; +} + +export interface TestWaiter { + beginAsync(): unknown; + endAsync(token: unknown): void; +} + +// Returns a waiter whose real `@ember/test-waiters` backing is resolved lazily. +// This lets modules build their waiter at import time (before the host has had +// a chance to inject the real implementation) without registering a stray +// waiter in production. +export function buildWaiter(label: string): TestWaiter { + let real: ReturnType | undefined; + let resolve = () => { + if (!real && injectedWaiters) { + real = injectedWaiters.buildWaiter(label); + } + return real; + }; + return { + beginAsync() { + return resolve()?.beginAsync(); + }, + endAsync(token: unknown) { + if (token === undefined) { + return; + } + resolve()?.endAsync(token); + }, + }; +} + +export function waitForPromise( + promise: Promise, + label?: string, +): Promise { + if (injectedWaiters) { + return injectedWaiters.waitForPromise(promise, label); + } + return promise; +}