fix(studio): stop HMR crashing the studio with a phantom missing provider

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.
This commit is contained in:
Vance Ingalls
2026-08-20 02:19:41 -07:00
parent 5175891b62
commit 94ce6ef61b
10 changed files with 93 additions and 31 deletions
@@ -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<NLEContextValue | null>(null);
const NLEContext = createStableContext<NLEContextValue | null>("NLEContext", null);
export function useNLEContext(): NLEContextValue {
const ctx = useContext(NLEContext);
@@ -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<DesignPanelInputContextValue>({
ui: "classic",
section: "unknown",
});
const DesignPanelInputContext = createStableContext<DesignPanelInputContextValue>(
"DesignPanelInputContext",
{ ui: "classic", section: "unknown" },
);
export function DesignPanelInputProvider({
ui,
@@ -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<typeof useDomEditSession>;
@@ -93,8 +94,14 @@ export interface DomEditSelectionValue extends Pick<
| "agentPromptSelectionContext"
> {}
const DomEditActionsContext = createContext<DomEditActionsValue | null>(null);
const DomEditSelectionContext = createContext<DomEditSelectionValue | null>(null);
const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
"DomEditActionsContext",
null,
);
const DomEditSelectionContext = createStableContext<DomEditSelectionValue | null>(
"DomEditSelectionContext",
null,
);
export function useDomEditActionsContext(): DomEditActionsValue {
const ctx = useContext(DomEditActionsContext);
@@ -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<typeof useFileManager>;
const FileManagerContext = createContext<FileManagerValue | null>(null);
const FileManagerContext = createStableContext<FileManagerValue | null>("FileManagerContext", null);
export function useFileManagerContext(): FileManagerValue {
const ctx = useContext(FileManagerContext);
@@ -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<typeof usePanelLayout>;
const PanelLayoutContext = createContext<PanelLayoutValue | null>(null);
const PanelLayoutContext = createStableContext<PanelLayoutValue | null>("PanelLayoutContext", null);
export function usePanelLayoutContext(): PanelLayoutValue {
const ctx = useContext(PanelLayoutContext);
@@ -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<StudioShellValue | null>(null);
const StudioPlaybackContext = createContext<StudioPlaybackValue | null>(null);
const StudioShellContext = createStableContext<StudioShellValue | null>("StudioShellContext", null);
const StudioPlaybackContext = createStableContext<StudioPlaybackValue | null>(
"StudioPlaybackContext",
null,
);
export function useStudioShellContext(): StudioShellValue {
const ctx = useContext(StudioShellContext);
@@ -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<TimelineEditCallbacks | null>(null);
const TimelineEditContext = createStableContext<TimelineEditCallbacks | null>(
"TimelineEditContext",
null,
);
export function useTimelineEditContext(): TimelineEditCallbacks {
const ctx = useContext(TimelineEditContext);
@@ -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<VariablePromoteContextValue | null>(null);
const VariablePromoteContext = createStableContext<VariablePromoteContextValue | null>(
"VariablePromoteContext",
null,
);
function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null {
const snapshot = session.getElement(hfId);
@@ -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<ViewModeValue | null>(null);
const ViewModeContext = createStableContext<ViewModeValue | null>("ViewModeContext", null);
export function useViewMode(): ViewModeValue {
const ctx = useContext(ViewModeContext);
@@ -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<string, Context<unknown>>;
function registry(): Registry {
const host = globalThis as unknown as Record<string, Registry | undefined>;
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<T>(name: string, defaultValue: T): Context<T> {
const store = registry();
const existing = store.get(name);
if (existing) return existing as Context<T>;
const created = createContext<T>(defaultValue);
store.set(name, created as Context<unknown>);
return created;
}