e38d587602
## 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. -->
113 lines
3.0 KiB
TypeScript
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;
|
|
}
|
|
};
|