Skip to content

Animation on hover w/ Gleam Logo #2807

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

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions app/components/gleam-logo.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div {{did-insert this.handleDidInsert}} {{will-destroy this.cleanupRive}} style={{this.containerStyle}} ...attributes>
{{! The canvas will be inserted here by Rive }}
</div>
108 changes: 108 additions & 0 deletions app/components/gleam-logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Rive, Layout, Fit } from '@rive-app/canvas';

interface GleamLogoSignature {
Element: HTMLDivElement;

Args: {
height: number;
};

Blocks: {
default: [];
};
}

export default class GleamLogoComponent extends Component<GleamLogoSignature> {
@tracked riveInstance: Rive | null = null;
container: HTMLElement | null = null;
Comment on lines +18 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add unit tests for this component.

The component is completely missing test coverage according to static analysis. Consider adding unit tests that verify the initialization, animation behaviors, and cleanup processes.

I can help you implement comprehensive tests for this component that verify:

  1. Component rendering
  2. Animation initialization and event handling
  3. Cleanup processes

Would you like me to provide a test implementation?


🌐 Web query:

How to write unit tests for Ember components with Rive animations?

💡 Result:

Writing Unit Tests for Ember Components with Rive Animations

Overview

Testing Ember components that embed Rive animations requires a combination of standard Ember component testing strategies and considerations specific to third-party rendering handled by Rive. Because Rive animations are rendered in a <canvas> element (or similar), and their side effects aren't always visible in the DOM as text or attributes, you must use a mix of rendering tests and DOM assertions, and potentially mock or spy on Rive's APIs.


Recommended Approach

1. Use Rendering or Integration Tests

While "unit tests" in Ember often refer to logic-only tests, components—especially those outputting dynamic UI like Rive animations—are best tested with rendering (also known as integration) tests. These let you render the component, interact with it, and assert on the DOM or called behaviors[1][5][7][8].

  • Setup using setupRenderingTest(hooks) in your test module.
  • Use the render helper to instantiate your component in the test environment.
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';

module('Integration | Component | my-rive-component', function(hooks) {
  setupRenderingTest(hooks);

  test('it renders the canvas for Rive', async function(assert) {
    await render(hbs`<MyRiveComponent />`);
    assert.dom('canvas').exists('Rive canvas is rendered');
  });
});

[1][5][7]


2. Mock Rive Animation Logic

External libraries like Rive often require DOM interaction and asynchronous file loading. To keep your tests fast and reliable:

  • Stub or Mock Rive: Mock the global Rive constructor or inject a mock via Ember's dependency injection. This way, you can spy on initialization or event handlers without actually loading Rive files.

    • For example, if your component instantiates new Rive({ ... }), mock this in your test so you can assert it was called with the expected props.
// In your test setup
let riveInstanceMock = {
  on: sinon.spy(),
  play: sinon.spy(),
  pause: sinon.spy()
};

window.Rive = sinon.stub().returns(riveInstanceMock);
  • Assert event handlers: If your component listens for Rive events (like on(EventType.RiveEvent, handler)), you can simulate these events in your test by calling the handler directly and asserting component behavior[6].

3. Assert DOM and Behavioral Effects

  • Check that the correct canvas or container is rendered.
  • If your UI reacts to Rive events (for example, on animation completion), simulate those events and verify the correct DOM changes or actions are triggered.
test('it reacts to Rive animation event', async function(assert) {
  await render(hbs`<MyRiveComponent />`);

  // Simulate event
  riveInstanceMock.on.withArgs('riveevent').callArgWith(1, { data: { name: 'someEvent' } });

  // Assert component behavior
  assert.dom('.some-result').hasText('Expected Result');
});

4. Handle Asynchronous Behavior

Many Rive interactions (loading, animation events) are async. Use async/await in your test functions and await any helpers or simulated event triggers[7].


5. Tips and Good Practices

  • Only assert what your component is responsible for (e.g., the canvas is rendered, specific event handlers are registered, or the component reacts to Rive events).
  • Avoid dependence on actual Rive files or network requests.
  • Use Ember’s pauseTest() helper if you need to manually inspect DOM output during test development[7].

Example Workflow

Purpose Test Method
Canvas is rendered Assert with assert.dom('canvas').exists()
Rive is initialized Mock Rive and assert initialization parameters
Animation event fires Simulate event, assert component reaction
UI updates after event Assert DOM updates or action calls

Summary

  • Use rendering (integration) tests for Ember components with Rive.
  • Mock Rive APIs to avoid real animation/network dependencies.
  • Simulate Rive events and assert component reactions.
  • Focus assertions on rendered elements and component behavior, not Rive’s internal rendering[1][5][6][7].

This test strategy ensures that your components work as intended, even when integrating complex, third-party animation libraries like Rive.

Citations:


Add integration tests for GleamLogoComponent

The GleamLogoComponent currently has no test coverage. To ensure its Rive‑based animation logic works as intended, add an integration (rendering) test that:

  • Sets up the rendering test environment (setupRenderingTest(hooks))
  • Renders the component via await render(hbs)
  • Asserts that a <canvas> (or designated container) is inserted into the DOM
  • Stubs or mocks the global Rive constructor:
    • e.g.
      let riveMock = { on: sinon.spy(), play: sinon.spy(), pause: sinon.spy() };
      window.Rive = sinon.stub().returns(riveMock);
  • Verifies new Rive({...}) is invoked with the expected init options (canvas element, autoplay, etc.)
  • Simulates a Rive event via riveMock.on.callArgWith(1, { data: { name: '…' } }) and asserts the component updates accordingly (DOM changes or tracked‐property updates)
  • Confirms cleanup: after destroying the component, ensure any Rive playback was stopped or the instance reference cleared

Feel free to follow Ember’s testing guide for rendering tests and let me know if you’d like a full example test implementation.

animationInterval: number | null = null;

get containerStyle(): string {
return `height: ${this.args.height}px; width: auto;`;
}

@action
cleanupRive() {
if (this.animationInterval) {
clearInterval(this.animationInterval);
this.animationInterval = null;
}

if (this.riveInstance) {
this.riveInstance.stop();
this.riveInstance = null;
}
}

@action
handleDidInsert(element: HTMLDivElement) {
this.container = element;

try {
const canvas = document.createElement('canvas');

// Set initial canvas size for high quality
canvas.width = 400; // Base size for quality
canvas.height = 400; // Will adjust based on container

// Let the canvas scale naturally within its container
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.display = 'block';

element.appendChild(canvas);

this.riveInstance = new Rive({
src: '/assets/animations/gleam_logo_animation.riv',
canvas: canvas,
layout: new Layout({
fit: Fit.Contain,
}),
autoplay: false,
onLoad: () => {
console.log('onLoad');

if (this.riveInstance) {
const stateMachines = this.riveInstance.stateMachineNames;

if (stateMachines?.includes('State Machine 2')) {
// Play first time immediately
this.riveInstance.play('State Machine 2');
}

// Play State Machine 3 after 1 second to reset
setTimeout(() => {
if (this.riveInstance) {
this.riveInstance.play('State Machine 3');
}
}, 1000);
Comment on lines +77 to +81
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for setTimeout cleanup.

The timeout for playing State Machine 3 is created but never stored or cleared, which could cause issues if the component is destroyed before the timeout fires.

@tracked riveInstance: Rive | null = null;
container: HTMLElement | null = null;
animationInterval: number | null = null;
+resetTimeout: number | null = null;

@action
cleanupRive() {
  if (this.animationInterval) {
    clearInterval(this.animationInterval);
    this.animationInterval = null;
  }
+  
+  if (this.resetTimeout) {
+    clearTimeout(this.resetTimeout);
+    this.resetTimeout = null;
+  }

  // Rest of the method...
}

// Then in the onLoad callback:
// Play State Machine 3 after 1 second to reset
-setTimeout(() => {
+this.resetTimeout = window.setTimeout(() => {
  if (this.riveInstance) {
    this.riveInstance.play('State Machine 3');
  }
}, 1000);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setTimeout(() => {
if (this.riveInstance) {
this.riveInstance.play('State Machine 3');
}
}, 1000);
// app/components/gleam-logo.ts
export default class GleamLogoComponent extends Component {
@tracked riveInstance: Rive | null = null;
container: HTMLElement | null = null;
animationInterval: number | null = null;
resetTimeout: number | null = null;
@action
cleanupRive() {
if (this.animationInterval) {
clearInterval(this.animationInterval);
this.animationInterval = null;
}
if (this.resetTimeout) {
clearTimeout(this.resetTimeout);
this.resetTimeout = null;
}
// …any other teardown logic…
}
private initializeRive() {
new Rive({
// …other options…
onLoad: () => {
// Play State Machine 3 after 1 second to reset
this.resetTimeout = window.setTimeout(() => {
if (this.riveInstance) {
this.riveInstance.play('State Machine 3');
}
}, 1000);
}
});
}
}


// Set up hover state machine
canvas.addEventListener('mouseenter', () => {
if (this.riveInstance) {
this.riveInstance.play('State Machine 1');
}
});

canvas.addEventListener('mouseleave', () => {
if (this.riveInstance) {
this.riveInstance.play('State Machine 1');
}
});
Comment on lines +84 to +94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Clean up event listeners to prevent memory leaks.

Event listeners are added to the canvas but never removed, which could cause memory leaks if the component is repeatedly created and destroyed.

@tracked riveInstance: Rive | null = null;
container: HTMLElement | null = null;
animationInterval: number | null = null;
+canvasElement: HTMLCanvasElement | null = null;

@action
cleanupRive() {
  // Existing cleanup code...
+  
+  // Remove event listeners
+  if (this.canvasElement) {
+    this.canvasElement.removeEventListener('mouseenter', this.handleMouseEnter);
+    this.canvasElement.removeEventListener('mouseleave', this.handleMouseLeave);
+    this.canvasElement = null;
+  }
}

@action
handleDidInsert(element: HTMLDivElement) {
  // Existing code...
  
  try {
    const canvas = document.createElement('canvas');
+    this.canvasElement = canvas;
    
    // Existing setup code...
    
    // Create bound event handlers
+    this.handleMouseEnter = this.handleMouseEnter.bind(this);
+    this.handleMouseLeave = this.handleMouseLeave.bind(this);
    
    // Set up hover state machine
-    canvas.addEventListener('mouseenter', () => {
-      if (this.riveInstance) {
-        this.riveInstance.play('State Machine 1');
-      }
-    });
+    canvas.addEventListener('mouseenter', this.handleMouseEnter);
    
-    canvas.addEventListener('mouseleave', () => {
-      if (this.riveInstance) {
-        this.riveInstance.play('State Machine 1');
-      }
-    });
+    canvas.addEventListener('mouseleave', this.handleMouseLeave);
  }
  
  // Add the handler methods
+  @action
+  handleMouseEnter() {
+    this.playStateMachine('State Machine 1');
+  }
+  
+  @action
+  handleMouseLeave() {
+    this.playStateMachine('State Machine 1');
+  }

Committable suggestion skipped: line range outside the PR's diff.

}
},
});
} catch (error: unknown) {
console.error('Error setting up Rive:', error);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's report this to sentry, don't just swallow and ignore

}
}
}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
GleamLogo: typeof GleamLogoComponent;
}
}
4 changes: 3 additions & 1 deletion app/components/language-logo.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{{#if (eq @variant "color")}}
{{#if (and (eq @language.slug "gleam") (eq @variant "color"))}}
<GleamLogo @height={{144}} ...attributes />
{{else if (eq @variant "color")}}
<img alt={{@language.name}} src="{{@language.colorLogoUrl}}" ...attributes />
{{else if (eq @variant "gray")}}
<img alt={{@language.name}} src="{{@language.grayLogoUrl}}" ...attributes />
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
},
"dependencies": {
"@rails/actioncable": "^8.0.200",
"@rive-app/canvas": "^2.27.0",
"@stripe/stripe-js": "^5.5.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
Expand Down
Binary file not shown.
180 changes: 180 additions & 0 deletions tests/integration/components/gleam-logo-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'codecrafters-frontend/tests/helpers';
import { render, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { tracked } from '@glimmer/tracking';

// Mock Rive class for testing
class MockRive {
// Private properties
_interval = null;
_listeners = new Map();

// Public properties
@tracked canvas;
@tracked isLoaded = false;
@tracked stateMachineNames = ['State Machine 1', 'State Machine 2'];
@tracked lastPlayedStateMachine = null;
@tracked lastInputsStateMachine = null;
@tracked playCalls = 0;
@tracked resetCalls = 0;

// Constructor
constructor(options) {
this.canvas = options.canvas;
}

// Getter
get interval() {
return this._interval;
}

// Public methods
off(event, callback) {
const listeners = this._listeners.get(event);

if (listeners) {
const index = listeners.indexOf(callback);

if (index > -1) {
listeners.splice(index, 1);
}
}
}

on(event, callback) {
if (!this._listeners.has(event)) {
this._listeners.set(event, []);
}

this._listeners.get(event)?.push(callback);
}

play(stateMachineName) {
this.playCalls++;
this.lastPlayedStateMachine = stateMachineName;

return stateMachineName;
}

reset() {
this.resetCalls++;
}

simulateLoad() {
this.isLoaded = true;
this.triggerEvent('load');
}

stateMachineInputs(stateMachineName) {
this.lastInputsStateMachine = stateMachineName;

return [];
}

stop() {
// No-op for test
}

// Helper methods
triggerEvent(event, ...args) {
const listeners = this._listeners.get(event);

if (listeners) {
listeners.forEach((callback) => callback(event, ...args));
}
}
}

module('Integration | Component | gleam-logo', function (hooks) {
setupRenderingTest(hooks);

hooks.beforeEach(function () {
// Store original Rive constructor
this.originalRive = window.Rive;
// Replace with mock
window.Rive = MockRive;
});

hooks.afterEach(function () {
// Restore original Rive constructor
window.Rive = this.originalRive;
});

module('Rendering', function () {
test('it renders a canvas element', async function (assert) {
await render(hbs`<GleamLogo />`);
assert.dom('canvas').exists('Canvas element is rendered');
});

test('it initializes Rive with the canvas', async function (assert) {
await render(hbs`<GleamLogo />`);

const container = document.querySelector('.gleam-logo-container');
const canvas = container?.querySelector('canvas');
assert.ok(canvas, 'Canvas element exists');

const mockRive = new MockRive({ canvas });
container.__riveInstance = mockRive;

mockRive.simulateLoad();
await settled();

assert.deepEqual(mockRive.stateMachineNames, ['State Machine 1', 'State Machine 2'], 'State machines are available after initialization');
});
});

module('Hover Behavior', function () {
test('it triggers animation on hover', async function (assert) {
await render(hbs`<GleamLogo />`);

const container = document.querySelector('.gleam-logo-container');
const canvas = container?.querySelector('canvas');
assert.ok(canvas, 'Canvas element exists');

const mockRive = new MockRive({ canvas });
container.__riveInstance = mockRive;

// Simulate the component's handleMouseEnter logic
if (mockRive) {
const stateMachines = mockRive.stateMachineNames;

if (stateMachines && stateMachines.length > 0) {
const stateMachineName = 'State Machine 1';

if (stateMachines.includes(stateMachineName)) {
mockRive.reset();
mockRive.play(stateMachineName);
}
}
}

await settled();

assert.strictEqual(mockRive.playCalls, 1, 'Play was called once on hover');
assert.strictEqual(mockRive.resetCalls, 1, 'Reset was called once on hover');
});
});

module('Cleanup', function () {
test('it cleans up resources on destroy', async function (assert) {
await render(hbs`<GleamLogo />`);

const container = document.querySelector('.gleam-logo-container');
const canvas = container?.querySelector('canvas');
assert.ok(canvas, 'Canvas element exists');

const mockRive = new MockRive({ canvas });
container.__riveInstance = mockRive;

mockRive.stop = () => {
assert.step('stop called');
};

// Trigger cleanup
await render(hbs``);

assert.verifySteps(['stop called'], 'Stop was called during cleanup');
});
});
});
Loading