-
Notifications
You must be signed in to change notification settings - Fork 1
RS-22108: Wait for fonts before laying out the heatmap #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
94d692f
RS-22108: wait for fonts before laying out the chart
JustinCCYap 0c0f0b7
RS-22108: stub the font set rather than the whole document in tests
JustinCCYap 85d7fc1
RS-22108: rebuild the widget bundle
JustinCCYap 9efdba3
RS-22108: simplify waitForFonts
JustinCCYap ca198e8
RS-22108: drop the header comment from the fonts module
JustinCCYap 3c2b386
RS-22108: drop the NB prefix from the comments added here
JustinCCYap f9fcf61
RS-22108: capitalise the comments added here
JustinCCYap de791db
RS-22108: own the widget status in one place
JustinCCYap 46fb48f
RS-22108: stop a font load throw from escaping waitForFonts
JustinCCYap 452cf36
RS-22108: only let the current render report the status
JustinCCYap 0974d08
RS-22108: guarantee a terminal status for every render
JustinCCYap 8667e94
RS-22108: keep a rejecting font set from stopping the render
JustinCCYap e5a3e55
RS-22108: cover the render status in heatmapOuter
JustinCCYap 6b17bbf
RS-22108: run lint, unit tests and the bundle compile in CI
JustinCCYap File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| name: JS tests | ||
|
|
||
| # Modelled on rhtmlCombinedScatter's js-tests.yaml, minus its visual job. The visual suite is not | ||
| # runnable here yet: this repo is on rhtmlBuildUtils 7.1.1, whose puppeteer 3.3.0 ships Chromium 83, | ||
| # whose --env is whitelisted to local/travis, whose --acceptNewSnapshots defaults to true so a missing | ||
| # baseline is silently written and passes, and whose jest-image-snapshot swallows mismatches per test. | ||
| # The committed baselines were also generated on Windows, so they would all fail on ubuntu at the | ||
| # 0.0001% threshold. Adding it is a rhtmlBuildUtils upgrade, not a workflow file. | ||
|
|
||
| on: | ||
| push: | ||
| workflow_dispatch: | ||
|
|
||
| # One in-flight run per branch; a new push supersedes the previous one. | ||
| concurrency: | ||
| group: js-tests-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| unit: | ||
| name: Unit tests and lint | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 20 | ||
| env: | ||
| # No browser is launched here, so skip the Chromium download. puppeteer 3.3.0 honours this. | ||
| PUPPETEER_SKIP_DOWNLOAD: 'true' | ||
| steps: | ||
| - uses: actions/checkout@v7 | ||
|
|
||
| - uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: 22 | ||
| cache: npm | ||
|
|
||
| # The preinstall hook self-skips when CI is true, which the runner sets. | ||
| - name: Install dependencies | ||
| id: install | ||
| run: npm ci | ||
|
|
||
| # Every step below runs even if an earlier one failed, so one failure does not hide the rest. | ||
| # Gated on the install succeeding, so a broken npm ci does not cascade. | ||
| - name: Lint | ||
| if: ${{ !cancelled() && steps.install.outcome == 'success' }} | ||
| run: npx gulp lint | ||
|
|
||
| - name: Unit tests | ||
| if: ${{ !cancelled() && steps.install.outcome == 'success' }} | ||
| run: npx gulp testSpecs | ||
|
|
||
| # Deliberately not `gulp build`: its `clean` step deletes the tracked man/ and R/, which only | ||
| # makeDocs can rebuild, and makeDocs needs R and devtools. This task is the compile check we | ||
| # want, and it earns its place in this repo because inst/htmlwidgets/ is committed, so it | ||
| # catches a source change pushed without a rebuild. | ||
| - name: Compile widget bundle | ||
| if: ${{ !cancelled() && steps.install.outcome == 'success' }} | ||
| run: npx gulp compileWidgetEntryPoint |
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| jest.mock('./lib/heatmapcore/heatmapcore', () => function Heatmap () {}) | ||
|
|
||
| const heatmapOuter = require('./heatmapOuter') | ||
|
|
||
| // The real Image decodes a data uri asynchronously. This stands in for it so that a test decides | ||
| // when, and whether, the load succeeds | ||
| class ControllableImage { | ||
| constructor () { | ||
| ControllableImage.instances.push(this) | ||
| this.onload = null | ||
| this.onerror = null | ||
| } | ||
|
|
||
| set src (uri) { this._src = uri } | ||
|
|
||
| static get latest () { return ControllableImage.instances[ControllableImage.instances.length - 1] } | ||
| } | ||
| ControllableImage.instances = [] | ||
|
|
||
| const config = () => ({ | ||
| options: { logLevel: 'silent' }, | ||
| image: 'data:image/png;base64,notarealimage', | ||
| matrix: { dim: [1, 1], data: [], cells_to_hide: [], cellnote_in_cell: [] }, | ||
| rows: null, | ||
| cols: null, | ||
| }) | ||
|
|
||
| const statusOf = element => element.getAttribute('rhtmlwidget-status') | ||
|
|
||
| describe('heatmapOuter', () => { | ||
| let element = null | ||
|
|
||
| beforeEach(() => { | ||
| ControllableImage.instances = [] | ||
| global.Image = ControllableImage | ||
| element = document.createElement('div') | ||
| document.body.appendChild(element) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| document.body.removeChild(element) | ||
| delete global.Image | ||
| }) | ||
|
|
||
| test('claims loading before any of the asynchronous work', () => { | ||
| heatmapOuter(element, config()) | ||
|
|
||
| expect(statusOf(element)).toEqual('loading') | ||
| }) | ||
|
|
||
| test('reports ready when the image cannot be loaded', async () => { | ||
| const rendering = heatmapOuter(element, config()) | ||
| ControllableImage.latest.onerror() | ||
|
|
||
| await expect(rendering).rejects.toThrow('failed to load the heatmap colour image') | ||
| expect(statusOf(element)).toEqual('ready') | ||
| }) | ||
|
|
||
| test('leaves the status alone when the render it belongs to has been superseded', async () => { | ||
| const rendering = heatmapOuter(element, config()) | ||
| const supersededImage = ControllableImage.latest | ||
|
|
||
| // What the factory does on a resize: discard the markup and render again from scratch | ||
| element.innerHTML = '' | ||
| heatmapOuter(element, config()) | ||
|
|
||
| supersededImage.onerror() | ||
|
|
||
| await expect(rendering).rejects.toThrow('failed to load the heatmap colour image') | ||
| expect(statusOf(element)).toEqual('loading') | ||
| }) | ||
|
|
||
| test('reports ready when the render fails synchronously', () => { | ||
| element.appendChild = () => { throw new Error('cannot append') } | ||
|
|
||
| expect(() => heatmapOuter(element, config())).toThrow('cannot append') | ||
| expect(statusOf(element)).toEqual('ready') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| const { fontFamiliesInUse, waitForFonts } = require('./fonts.js') | ||
|
|
||
| describe('fontFamiliesInUse', () => { | ||
| test('collects every font family option', () => { | ||
| expect(fontFamiliesInUse({ | ||
| title_font_family: 'Circular', | ||
| xaxis_font_family: 'Open Sans', | ||
| xaxis_font_size: 15, | ||
| yaxis_hidden: false, | ||
| })).toEqual(['Circular', 'Open Sans']) | ||
| }) | ||
|
|
||
| test('deduplicates, trims, and drops values that are not usable font families', () => { | ||
| expect(fontFamiliesInUse({ | ||
| title_font_family: 'Circular', | ||
| subtitle_font_family: ' Circular ', | ||
| footer_font_family: '', | ||
| legend_font_family: null, | ||
| cell_font_family: 12, | ||
| })).toEqual(['Circular']) | ||
| }) | ||
|
|
||
| test('returns nothing when no font families are configured', () => { | ||
| expect(fontFamiliesInUse({ xaxis_font_size: 15 })).toEqual([]) | ||
| expect(fontFamiliesInUse({})).toEqual([]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('waitForFonts', () => { | ||
| // document cannot be replaced wholesale under jsdom, so only the font set is stubbed, and | ||
| // a document is only invented when the test environment provides none | ||
| const documentWasInvented = (typeof document === 'undefined') | ||
| const originalFontSet = documentWasInvented ? undefined : document.fonts | ||
|
|
||
| const withFontSet = (fontSet) => { | ||
| if (documentWasInvented) { | ||
| global.document = {} | ||
| } | ||
| document.fonts = fontSet | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| if (documentWasInvented) { | ||
| delete global.document | ||
| } else { | ||
| document.fonts = originalFontSet | ||
| } | ||
| }) | ||
|
|
||
| test('requests each configured family in normal and bold, then waits on the font set', async () => { | ||
| const requested = [] | ||
| let readyHasResolved = false | ||
| withFontSet({ | ||
| load: (fontSpecification) => { requested.push(fontSpecification); return Promise.resolve([]) }, | ||
| ready: Promise.resolve().then(() => { readyHasResolved = true }), | ||
| }) | ||
|
|
||
| await waitForFonts({ title_font_family: 'Circular', xaxis_font_family: 'Circular' }) | ||
|
|
||
| expect(requested).toEqual(['12px "Circular"', 'bold 12px "Circular"']) | ||
| expect(readyHasResolved).toBe(true) | ||
| }) | ||
|
|
||
| test('quotes the family so a multi word name stays a parseable font shorthand', async () => { | ||
| const requested = [] | ||
| withFontSet({ | ||
| load: (fontSpecification) => { requested.push(fontSpecification); return Promise.resolve([]) }, | ||
| ready: Promise.resolve(), | ||
| }) | ||
|
|
||
| await waitForFonts({ title_font_family: 'Open Sans' }) | ||
|
|
||
| expect(requested).toEqual(['12px "Open Sans"', 'bold 12px "Open Sans"']) | ||
| }) | ||
|
|
||
| test('resolves when a font cannot be loaded', async () => { | ||
| withFontSet({ | ||
| load: () => Promise.reject(new Error('no such font')), | ||
| ready: Promise.resolve(), | ||
| }) | ||
|
|
||
| await expect(waitForFonts({ title_font_family: 'Circular' })).resolves.toBeUndefined() | ||
| }) | ||
|
|
||
| test('resolves when loading a font throws synchronously, as Blink does on an unparseable shorthand', async () => { | ||
| withFontSet({ | ||
| load: () => { throw new Error('Could not resolve as a font') }, | ||
| ready: Promise.resolve(), | ||
| }) | ||
|
|
||
| await expect(waitForFonts({ title_font_family: 'a "quoted" name' })).resolves.toBeUndefined() | ||
| }) | ||
|
|
||
| test('resolves when the font set has no load method', async () => { | ||
| withFontSet({ ready: Promise.resolve() }) | ||
|
|
||
| await expect(waitForFonts({ title_font_family: 'Circular' })).resolves.toBeUndefined() | ||
| }) | ||
|
|
||
| test('resolves when the font set rejects, which the spec forbids but a shim may do', async () => { | ||
| withFontSet({ | ||
| load: () => Promise.resolve([]), | ||
| ready: Promise.reject(new Error('not a conforming font set')), | ||
| }) | ||
|
|
||
| await expect(waitForFonts({ title_font_family: 'Circular' })).resolves.toBeUndefined() | ||
| }) | ||
|
|
||
| test('resolves without waiting for a font set the browser does not provide', async () => { | ||
| withFontSet(undefined) | ||
|
|
||
| await expect(waitForFonts({ title_font_family: 'Circular' })).resolves.toBeUndefined() | ||
| }) | ||
|
|
||
| test('stops waiting on a font set that never becomes ready', async () => { | ||
| jest.useFakeTimers() | ||
| try { | ||
| withFontSet({ load: () => Promise.resolve([]), ready: new Promise(() => {}) }) | ||
|
|
||
| const waiting = waitForFonts({ title_font_family: 'Circular' }) | ||
| jest.advanceTimersByTime(3000) | ||
|
|
||
| await expect(waiting).resolves.toBeUndefined() | ||
| } finally { | ||
| jest.useRealTimers() | ||
| } | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This claims
loadingsynchronously, but nothing guarantees a terminal status, so there's a path that ends up worse than before this PR.loadImage(line 92) still has noimg.onerror— the TODO on line 93 is untouched — so whenimg.src = urifails on an empty, malformed, or corrupt data URI, that promise never settles. The.catchbelow can't help, because there is no rejection to catch. The div then sits atrhtmlwidget-status=loadingforever. Pre-PR the attribute was simply never written, so per your own reasoning in the PR description the export treated the widget as not-loading and screenshotted immediately; now an image failure means waiting out the screenshot timeout instead — the exact outcome the comment on the.catchsays it's avoiding.Same hole for a synchronous throw between here and the
Promise.all—getContainerDimensions, or thed3.select(element)array-like path. That escapes before any promise exists, so again there's no rejection and the status staysloading.Two changes close it:
img.onerror = rejectinloadImage, and a try/catch around this block that reportsreadyon a synchronous failure.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both closed in 0974d08.
img.onerror = () => reject(...)inloadImage, so an unloadable data URI now rejects and the existing.catchreports ready instead of the chain hanging. That was the real hole, and it removes the TODO too.One correction on the second half:
getContainerDimensionsruns at line 21, above theloadingclaim at line 26, so a throw there leaves the attribute unwritten exactly as it was pre-PR. The window is onlyd3.select(element).append('svg')andnew Image(), both of which are now inside a try/catch that reports ready before rethrowing. That branch writes unconditionally rather than throughisCurrentRender()— no newer render can have started while this one is still synchronous, and if the throw came from theappenditself there is no svg for the check to find.