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,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);