mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user