Fix frontend HMR so the page doesn't break upon saving TS files and restructure frontend architecture (#3871)

* Clean up component setup/tear-down side effects

* Clean up more component setup/tear-down side effects

* Remove nonfunctional debouncer

* Clean up even more component setup/tear-down side effects

* Reuse backend state

* Fix HMR for IO Managers and for State Providers

* Rename IO Managers -> Managers and State Providers -> Stores

* Restructure and partially flatten managers/stores

* Code review fixes

* Review fixes
This commit is contained in:
Keavon Chambers
2026-03-19 13:38:41 -07:00
committed by GitHub
parent eb562146ce
commit 124b17f609
54 changed files with 1385 additions and 931 deletions
@@ -4,7 +4,6 @@
import type { LayoutTarget, Widget, WidgetInstance } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Editor } from "@graphite/editor";
import { parseFillChoice } from "@graphite/utility-functions/colors";
import { debouncer } from "@graphite/utility-functions/debounce";
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
import BreadcrumbTrailButtons from "@graphite/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
@@ -137,7 +136,7 @@
getProps: (props, index) => ({
...props,
$$events: {
value: (e: CustomEvent) => debouncer((value: unknown) => widgetValueCommitAndUpdate(index, value, false), { debounceTime: 120 }).debounceUpdateValue(e.detail),
value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, false),
},
}),
},
@@ -202,7 +201,7 @@
incrementCallbackIncrease: () => widgetValueCommitAndUpdate(index, "Increment", false),
incrementCallbackDecrease: () => widgetValueCommitAndUpdate(index, "Decrement", false),
$$events: {
value: (e: CustomEvent) => debouncer((value: unknown) => widgetValueUpdate(index, value, true)).debounceUpdateValue(e.detail),
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, true),
startHistoryTransaction: () => widgetValueCommit(index, props.value),
},
}),
@@ -2,7 +2,6 @@
import { createEventDispatcher } from "svelte";
import type { Curve, CurveManipulatorGroup, ActionShortcut } from "@graphite/../wasm/pkg/graphite_wasm";
import { clamp } from "@graphite/utility-functions/math";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
@@ -185,6 +184,10 @@
dAttribute = recalculateSvgPath();
updateCurve();
}
function clamp(value: number, min = 0, max = 1): number {
return Math.max(min, Math.min(value, max));
}
</script>
<LayoutRow class="curve-input" {tooltipLabel} {tooltipDescription} {tooltipShortcut}>
@@ -4,7 +4,7 @@
import { evaluateMathExpression, isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm";
import type { NumberInputMode, NumberInputIncrementBehavior, ActionShortcut } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Editor } from "@graphite/editor";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/managers/input";
import { browserVersion } from "@graphite/utility-functions/platform";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
@@ -87,6 +87,12 @@
let shiftKeyDown = false;
// Track whether the Ctrl key is currently held down.
let ctrlKeyDown = false;
// Cleanup function for active drag interactions, called on destroy to prevent leaked listeners
let activeDragCleanup: (() => void) | undefined;
// Track the slider abort state for cleanup on destroy
let sliderResetAbortHandler: (() => void) | undefined;
let sliderAbortTimeout1: ReturnType<typeof setTimeout> | undefined;
let sliderAbortTimeout2: ReturnType<typeof setTimeout> | undefined;
$: watchValue(value, unit);
$: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
@@ -107,10 +113,31 @@
addEventListener("mousemove", trackShiftAndCtrl);
});
onDestroy(() => {
clearTimeout(repeatTimeout);
clearTimeout(sliderAbortTimeout1);
clearTimeout(sliderAbortTimeout2);
activeDragCleanup?.();
// Exit pointer lock if active (non-Safari path)
if (document.pointerLockElement) document.exitPointerLock();
// Remove Safari cursor-hidden workaround class if present
const isSafari = browserVersion().toLowerCase().includes("safari");
if (isSafari) document.body.classList.remove("cursor-hidden");
// Clean up any listeners related to tracking the Shift and Ctrl keys
removeEventListener("keydown", trackShiftAndCtrl);
removeEventListener("keyup", trackShiftAndCtrl);
removeEventListener("mousemove", trackShiftAndCtrl);
clearTimeout(repeatTimeout);
// Clean up any slider-related listeners that may be active
removeEventListener("mousedown", sliderAbortFromMousedown);
removeEventListener("keydown", sliderAbortFromMousedown);
removeEventListener("pointermove", sliderAbortFromDragging);
removeEventListener("keydown", sliderAbortFromDragging);
removeEventListener("keydown", incrementPressAbort);
if (sliderResetAbortHandler) removeEventListener("pointerup", sliderResetAbortHandler);
});
// ===============================
@@ -297,7 +324,7 @@
pressingArrow = false;
clearTimeout(repeatTimeout);
updateValue(initialValueBeforeDragging);
removeEventListener("keydown", onIncrementPointerUp);
removeEventListener("keydown", incrementPressAbort);
}
// =======================================
@@ -333,10 +360,9 @@
alreadyActedGuard = true;
isDragging = true;
beginDrag(e);
removeEventListener("pointermove", onMove);
removeEventListener("pointerup", onUp);
activeDragCleanup?.();
beginDrag(e);
};
// If it's a mouseup, we'll begin editing the text field.
const onUp = () => {
@@ -346,11 +372,15 @@
isDragging = false;
self?.focus();
removeEventListener("pointermove", onMove);
removeEventListener("pointerup", onUp);
activeDragCleanup?.();
};
addEventListener("pointermove", onMove);
addEventListener("pointerup", onUp);
activeDragCleanup = () => {
removeEventListener("pointermove", onMove);
removeEventListener("pointerup", onUp);
activeDragCleanup = undefined;
};
}
function beginDrag(e: PointerEvent) {
@@ -449,16 +479,20 @@
cumulativeDragDelta = 0;
// Clean up the event listeners.
removeEventListener("pointerup", pointerUp);
removeEventListener("pointermove", pointerMove);
removeEventListener("pointerlockmove", pointerLockMove);
if (usePointerLock) document.removeEventListener("pointerlockchange", pointerLockChange);
activeDragCleanup?.();
};
addEventListener("pointerup", pointerUp);
addEventListener("pointermove", pointerMove);
addEventListener("pointerlockmove", pointerLockMove);
if (usePointerLock) document.addEventListener("pointerlockchange", pointerLockChange);
activeDragCleanup = () => {
removeEventListener("pointerup", pointerUp);
removeEventListener("pointermove", pointerMove);
removeEventListener("pointerlockmove", pointerLockMove);
if (usePointerLock) document.removeEventListener("pointerlockchange", pointerLockChange);
activeDragCleanup = undefined;
};
}
function pointerLockMoveUpdate(delta: number, slow: boolean, snapping: boolean, initialValue: number) {
@@ -657,7 +691,7 @@
// End the user's drag by instantaneously disabling and re-enabling the range input element
if (inputRangeElement) inputRangeElement.disabled = true;
setTimeout(() => {
sliderAbortTimeout1 = setTimeout(() => {
if (inputRangeElement) inputRangeElement.disabled = false;
}, 0);
@@ -680,11 +714,13 @@
// dragging the slider, hitting Escape, then releasing the mouse button. This results in being transferred by `onSliderInput()` to the
// "Deciding" state when we should remain in the "Ready" state as set here. (For debugging, this can be visualized in CSS by
// recoloring the fake slider handle, which is shown in the "Deciding" state.)
setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
sliderAbortTimeout2 = setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
// Clean up the event listener that was used to call this function.
removeEventListener("pointerup", sliderResetAbort);
sliderResetAbortHandler = undefined;
};
sliderResetAbortHandler = sliderResetAbort;
addEventListener("pointerup", sliderResetAbort);
// Clean up the event listeners that were for tracking an abort while dragging the slider, now that we're no longer dragging it.
@@ -1,7 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { createEventDispatcher, onDestroy } from "svelte";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/io-managers/input";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/managers/input";
const ARROW_CLICK_DISTANCE = 0.05;
const ARROW_REPEAT_DISTANCE = 0.01;
@@ -187,6 +187,10 @@
if (e.key === "Escape") abortInteraction();
}
onDestroy(() => {
removeEvents();
});
function addEvents() {
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointermove", onPointerMove);
@@ -4,7 +4,7 @@
</script>
<script lang="ts">
import { createEventDispatcher, onDestroy } from "svelte";
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { evaluateGradientAtPosition } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Color, GradientStops } from "@graphite/../wasm/pkg/graphite_wasm";
@@ -338,7 +338,9 @@
document.removeEventListener("keydown", onKeyDown);
}
document.addEventListener("keydown", deleteStop);
onMount(() => {
document.addEventListener("keydown", deleteStop);
});
onDestroy(() => {
removeEvents();
document.removeEventListener("keydown", deleteStop);
@@ -62,7 +62,10 @@
}
onMount(() => watchForCheckbox(forCheckbox));
onDestroy(() => watchForCheckbox(undefined));
onDestroy(() => {
handlePointerLeave();
watchForCheckbox(undefined);
});
</script>
<label