Files
twenty/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/createWelcomeHalftoneWorkerController.ts
T
Raphaël Bosi e38d587602 Welcome animation when a workspace is set up (#22622)
## Desktop


https://github.com/user-attachments/assets/87999ff5-9257-4fed-90e0-781322b1db98

## Mobile



https://github.com/user-attachments/assets/0d115cb6-7bca-4229-8fde-0f188e862b00


Greets the user the moment they finish onboarding: a halftone Twenty
mark (brand purple) assembles from scattered particles, shimmers, then
bursts outward to reveal the freshly loaded workspace behind it. Plays
once, ~3s, with a reduced-motion fallback.

`PageChangeEffect` sets a transient `isWelcomeAnimationVisibleState`
atom at the onboarding to workspace redirect seam (gated by
`shouldShowWelcomeAnimationOnNavigate`); `WelcomeOverlay`, mounted in
`WorkspaceAppProviders`, plays it and clears it. Transient by design, so
it never replays on reload.

## Why a canvas rendered on another thread

The overlay plays at the exact moment the workspace mounts behind it,
which is one of the heaviest main-thread stretches in the app (metadata,
providers, first data fetch). Anything animating on the main thread
competes with that work:

- framer-motion and plain main-thread `requestAnimationFrame` visibly
stuttered and froze during the mount, because long tasks starve rAF.
- CSS keyframes stay smooth (they run on the compositor), but can't
drive a ~2900-particle halftone with per-particle physics (staggered
assemble, moving shimmer band, directional burst).

So the halftone runs as a Canvas 2D particle system inside a Web Worker,
drawing to an `OffscreenCanvas` transferred from the main thread. The
whole render loop lives off the main thread, so app-mount jank can't
reach it. A main-thread path is kept as a fallback for browsers without
`OffscreenCanvas` / `transferControlToOffscreen`, and Safari workers
(which lack rAF) fall back to a fixed-interval loop. The renderer is a
pure, DOM-free module so it bundles cleanly into the worker; dot colors
come from CSS vars read on the main thread and passed in.

Also fires for every onboarding completion path and skips the billing
`PlanRequired` detour.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22622?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-09 08:47:07 +00:00

113 lines
3.0 KiB
TypeScript

import { isDefined } from 'twenty-shared/utils';
import { type WelcomeHalftoneController } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type';
import WelcomeHalftoneWorker from './welcomeHalftone.worker?worker';
const WORKER_READY_TIMEOUT_MS = 1500;
type CreateWelcomeHalftoneWorkerControllerOptions = {
canvas: HTMLCanvasElement;
dotColor: string;
dotHighlightColor: string;
prefersReducedMotion: boolean;
initialCanvasWidth: number;
initialCanvasHeight: number;
devicePixelRatio: number;
onWorkerUnavailable: () => void;
};
export const createWelcomeHalftoneWorkerController = ({
canvas,
dotColor,
dotHighlightColor,
prefersReducedMotion,
initialCanvasWidth,
initialCanvasHeight,
devicePixelRatio,
onWorkerUnavailable,
}: CreateWelcomeHalftoneWorkerControllerOptions): WelcomeHalftoneController | null => {
const isOffscreenCanvasSupported =
typeof Worker !== 'undefined' &&
typeof OffscreenCanvas !== 'undefined' &&
typeof canvas.transferControlToOffscreen === 'function';
if (!isOffscreenCanvasSupported) {
return null;
}
let worker: Worker | null = null;
let workerReadyTimeoutId: ReturnType<typeof setTimeout> | null = null;
let hasWorkerSettled = false;
let hasCanvasBeenTransferred = false;
const settleWorker = () => {
hasWorkerSettled = true;
if (isDefined(workerReadyTimeoutId)) {
clearTimeout(workerReadyTimeoutId);
workerReadyTimeoutId = null;
}
};
const handleWorkerUnavailable = () => {
if (hasWorkerSettled) {
return;
}
settleWorker();
worker?.terminate();
onWorkerUnavailable();
};
try {
worker = new WelcomeHalftoneWorker();
workerReadyTimeoutId = setTimeout(
handleWorkerUnavailable,
WORKER_READY_TIMEOUT_MS,
);
worker.onerror = handleWorkerUnavailable;
worker.onmessage = (event: MessageEvent<{ type?: string }>) => {
if (event.data?.type === 'ready') {
settleWorker();
}
};
const offscreenCanvas = canvas.transferControlToOffscreen();
hasCanvasBeenTransferred = true;
worker.postMessage(
{
type: 'init',
canvas: offscreenCanvas,
width: initialCanvasWidth,
height: initialCanvasHeight,
devicePixelRatio,
color: dotColor,
highlightColor: dotHighlightColor,
reducedMotion: prefersReducedMotion,
},
[offscreenCanvas],
);
return {
leave: () => worker?.postMessage({ type: 'leave' }),
resize: (nextCanvasWidth, nextCanvasHeight, nextDevicePixelRatio) =>
worker?.postMessage({
type: 'resize',
width: nextCanvasWidth,
height: nextCanvasHeight,
devicePixelRatio: nextDevicePixelRatio,
}),
destroy: () => {
settleWorker();
worker?.postMessage({ type: 'stop' });
worker?.terminate();
},
};
} catch {
settleWorker();
worker?.terminate();
if (hasCanvasBeenTransferred) {
onWorkerUnavailable();
}
return null;
}
};