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