From 77308f4f3725f0076075fb8dd4185d7618772f3d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 19 Aug 2026 11:07:05 -0700 Subject: [PATCH] fix(studio): stop HMR crashing the studio with a phantom missing provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing almost anything took the studio down with "useNLEContext must be used within an NLEProvider" — while the React component stack printed the consumer nested inside that very provider. I told you to reload it. That was not an answer: it happened on every edit. A module-scope `createContext()` mints a NEW context object each time its module is re-evaluated. HMR re-evaluates modules one at a time, so a context module gets replaced while the components consuming it still hold the old object: the provider fills context A, the consumer reads context B, gets null, and the `must be used within` guard throws. Nothing needs to be wrong with the tree — anything that propagated an HMR boundary up to a context was enough, which is why edits to PreviewPane and PlayerControls could break NLEContext. `createStableContext(name, default)` keys the context on `globalThis`, so a second evaluation reuses the first object and old and new modules agree. Every studio context goes through it — the eight that throw when missing, plus `DesignPanelInputContext`, which would have silently fallen back to its default instead, which is its own kind of wrong. Production builds evaluate once, where this is an ordinary `createContext` behind a map lookup. Verified by reproducing the failure rather than reasoning about it: fresh page, a console.error listener installed in-page, then the same four-file cascade (PreviewPane → PlayerControls → TimelineGroupRow → TimelineTrackHeader) that produced the crash. Before: the app fell to the error boundary. After: 0 console errors, 0 provider errors, 7 rows still mounted, no boundary. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, studio suite 4326 green. --- .../studio/src/components/nle/NLEContext.tsx | 13 ++--- .../src/contexts/DesignPanelInputContext.tsx | 11 +++-- .../studio/src/contexts/DomEditContext.tsx | 13 +++-- .../src/contexts/FileManagerContext.tsx | 5 +- .../src/contexts/PanelLayoutContext.tsx | 5 +- .../studio/src/contexts/StudioContext.tsx | 10 ++-- .../src/contexts/TimelineEditContext.tsx | 8 +++- .../src/contexts/VariablePromoteContext.tsx | 8 +++- .../studio/src/contexts/ViewModeContext.tsx | 4 +- packages/studio/src/utils/hmrStableContext.ts | 47 +++++++++++++++++++ 10 files changed, 93 insertions(+), 31 deletions(-) create mode 100644 packages/studio/src/utils/hmrStableContext.ts diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 5f5ae4293..d71d7ef52 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -1,12 +1,4 @@ -import { - createContext, - useContext, - useState, - useCallback, - useRef, - useEffect, - type ReactNode, -} from "react"; +import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react"; import { useTimelinePlayer, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; import type { CompositionLevel } from "./CompositionBreadcrumb"; @@ -16,6 +8,7 @@ import { setCompositionSourceMap } from "../editor/domEditingDom"; import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; +import { createStableContext } from "../../utils/hmrStableContext"; // Timeline gets a generous default height so the preview isn't oversized and the // tracks have room to breathe (CapCut-style). Users can still drag the divider. @@ -55,7 +48,7 @@ export interface NLEContextValue { setPreviewCompositionSize: (size: { width: number; height: number } | null) => void; } -const NLEContext = createContext(null); +const NLEContext = createStableContext("NLEContext", null); export function useNLEContext(): NLEContextValue { const ctx = useContext(NLEContext); diff --git a/packages/studio/src/contexts/DesignPanelInputContext.tsx b/packages/studio/src/contexts/DesignPanelInputContext.tsx index a4b4223c0..25c2e68fc 100644 --- a/packages/studio/src/contexts/DesignPanelInputContext.tsx +++ b/packages/studio/src/contexts/DesignPanelInputContext.tsx @@ -1,4 +1,5 @@ -import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; +import { useCallback, useContext, useMemo, type ReactNode } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking"; // Carries which inspector UI and which section the currently-rendered design-panel @@ -11,10 +12,10 @@ interface DesignPanelInputContextValue { section: string; } -const DesignPanelInputContext = createContext({ - ui: "classic", - section: "unknown", -}); +const DesignPanelInputContext = createStableContext( + "DesignPanelInputContext", + { ui: "classic", section: "unknown" }, +); export function DesignPanelInputProvider({ ui, diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 365504b8d..31210b3f7 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -1,6 +1,7 @@ // fallow-ignore-file code-duplication -import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; import type { useDomEditSession } from "../hooks/useDomEditSession"; +import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; type DomEditValue = ReturnType; @@ -93,8 +94,14 @@ export interface DomEditSelectionValue extends Pick< | "agentPromptSelectionContext" > {} -const DomEditActionsContext = createContext(null); -const DomEditSelectionContext = createContext(null); +const DomEditActionsContext = createStableContext( + "DomEditActionsContext", + null, +); +const DomEditSelectionContext = createStableContext( + "DomEditSelectionContext", + null, +); export function useDomEditActionsContext(): DomEditActionsValue { const ctx = useContext(DomEditActionsContext); diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx index 73270943a..a491555e3 100644 --- a/packages/studio/src/contexts/FileManagerContext.tsx +++ b/packages/studio/src/contexts/FileManagerContext.tsx @@ -1,9 +1,10 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { useFileManager } from "../hooks/useFileManager"; +import { useContext, useMemo, type ReactNode } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; type FileManagerValue = ReturnType; -const FileManagerContext = createContext(null); +const FileManagerContext = createStableContext("FileManagerContext", null); export function useFileManagerContext(): FileManagerValue { const ctx = useContext(FileManagerContext); diff --git a/packages/studio/src/contexts/PanelLayoutContext.tsx b/packages/studio/src/contexts/PanelLayoutContext.tsx index 6666fccbc..c37ff8474 100644 --- a/packages/studio/src/contexts/PanelLayoutContext.tsx +++ b/packages/studio/src/contexts/PanelLayoutContext.tsx @@ -1,9 +1,10 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { usePanelLayout } from "../hooks/usePanelLayout"; +import { useContext, useMemo, type ReactNode } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; type PanelLayoutValue = ReturnType; -const PanelLayoutContext = createContext(null); +const PanelLayoutContext = createStableContext("PanelLayoutContext", null); export function usePanelLayoutContext(): PanelLayoutValue { const ctx = useContext(PanelLayoutContext); diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index 5de449ffa..97b8ac0be 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -1,7 +1,8 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { TimelineElement } from "../player"; import type { CompositionDimensions } from "../components/renders/RenderQueue"; import type { FfmpegStatus } from "../components/renders/useFfmpegStatus"; +import { useContext, useMemo, type ReactNode } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; export interface StudioShellValue { projectId: string; @@ -52,8 +53,11 @@ export interface StudioPlaybackValue { export type StudioContextValue = StudioShellValue & StudioPlaybackValue; -const StudioShellContext = createContext(null); -const StudioPlaybackContext = createContext(null); +const StudioShellContext = createStableContext("StudioShellContext", null); +const StudioPlaybackContext = createStableContext( + "StudioPlaybackContext", + null, +); export function useStudioShellContext(): StudioShellValue { const ctx = useContext(StudioShellContext); diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 22c82cdf0..4bdedcfbb 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -1,7 +1,11 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { useContext, useMemo, type ReactNode } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks"; -const TimelineEditContext = createContext(null); +const TimelineEditContext = createStableContext( + "TimelineEditContext", + null, +); export function useTimelineEditContext(): TimelineEditCallbacks { const ctx = useContext(TimelineEditContext); diff --git a/packages/studio/src/contexts/VariablePromoteContext.tsx b/packages/studio/src/contexts/VariablePromoteContext.tsx index 18fac0cb5..1190cd1bf 100644 --- a/packages/studio/src/contexts/VariablePromoteContext.tsx +++ b/packages/studio/src/contexts/VariablePromoteContext.tsx @@ -7,7 +7,6 @@ * and callbacks to promote or to edit the bound variable's default in place. */ -import { createContext, useContext, useEffect, useMemo, useState } from "react"; import type { Composition, CompositionVariable } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { @@ -22,6 +21,8 @@ import { uniqueId, type PromoteChannel, } from "./variablePromoteHelpers"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; export type { PromoteChannel }; @@ -47,7 +48,10 @@ interface VariablePromoteContextValue { onPersistError: (error: unknown) => void; } -const VariablePromoteContext = createContext(null); +const VariablePromoteContext = createStableContext( + "VariablePromoteContext", + null, +); function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null { const snapshot = session.getElement(hfId); diff --git a/packages/studio/src/contexts/ViewModeContext.tsx b/packages/studio/src/contexts/ViewModeContext.tsx index 9370b88a5..adb7b13c2 100644 --- a/packages/studio/src/contexts/ViewModeContext.tsx +++ b/packages/studio/src/contexts/ViewModeContext.tsx @@ -1,5 +1,4 @@ import { - createContext, useCallback, useContext, useEffect, @@ -8,6 +7,7 @@ import { useState, type ReactNode, } from "react"; +import { createStableContext } from "../utils/hmrStableContext"; /** * Top-level Studio view mode. @@ -123,7 +123,7 @@ export function useViewModeState(): ViewModeValue { ); } -const ViewModeContext = createContext(null); +const ViewModeContext = createStableContext("ViewModeContext", null); export function useViewMode(): ViewModeValue { const ctx = useContext(ViewModeContext); diff --git a/packages/studio/src/utils/hmrStableContext.ts b/packages/studio/src/utils/hmrStableContext.ts new file mode 100644 index 000000000..68407e745 --- /dev/null +++ b/packages/studio/src/utils/hmrStableContext.ts @@ -0,0 +1,47 @@ +/** + * `createContext`, but stable across Vite HMR re-evaluations. + * + * A module-scope `createContext()` mints a NEW context object every time its + * module is re-evaluated. HMR re-evaluates modules one at a time, so a context + * module can be replaced while the components consuming it still hold the old + * object — the provider then fills context A while the consumer reads context + * B, gets `null`, and a `useX must be used within an XProvider` guard throws. + * + * The symptom is unmistakable and misleading: the React component stack shows + * the consumer nested INSIDE the very provider it claims to be missing. It + * crashed the studio on edits to files nowhere near the context — anything that + * propagated an HMR boundary up to it was enough. + * + * Keying the context on `globalThis` by a stable name makes the second + * evaluation reuse the first object, so old and new modules agree. Production + * builds evaluate once, where this is an ordinary `createContext` with a map + * lookup in front of it. + */ + +import { createContext, type Context } from "react"; + +const REGISTRY = "__hfStudioContexts"; + +type Registry = Map>; + +function registry(): Registry { + const host = globalThis as unknown as Record; + const existing = host[REGISTRY]; + if (existing) return existing; + const created: Registry = new Map(); + host[REGISTRY] = created; + return created; +} + +/** + * `name` must be unique per context and stable across reloads — the module path + * plus the export name is the convention here. + */ +export function createStableContext(name: string, defaultValue: T): Context { + const store = registry(); + const existing = store.get(name); + if (existing) return existing as Context; + const created = createContext(defaultValue); + store.set(name, created as Context); + return created; +}