diff --git a/.chronus/changes/playground-preserve-sample-config-2026-08-13.md b/.chronus/changes/playground-preserve-sample-config-2026-08-13.md new file mode 100644 index 00000000000..975fc5db36d --- /dev/null +++ b/.chronus/changes/playground-preserve-sample-config-2026-08-13.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/playground" +--- + +Preserve a sample's preferred emitter and compiler options when loading it in the playground. diff --git a/packages/playground/src/react/hooks/use-monaco-sync.ts b/packages/playground/src/react/hooks/use-monaco-sync.ts index d1e59d1213b..0af0c5e43c2 100644 --- a/packages/playground/src/react/hooks/use-monaco-sync.ts +++ b/packages/playground/src/react/hooks/use-monaco-sync.ts @@ -23,6 +23,17 @@ export function useMonacoSync({ // to avoid the sync effect resetting the model during typing const isModelDrivenChangeRef = useRef(false); + // Keep the listener's view of React state current before syncing an external + // content change into Monaco. `setValue` fires `onDidChangeContent` + // synchronously, so updating these refs after the sync effect would let that + // event call a stale callback with stale content. + const contentRef = useRef(content); + const onContentChangeRef = useRef(onContentChange); + useEffect(() => { + contentRef.current = content; + onContentChangeRef.current = onContentChange; + }, [content, onContentChange]); + // Sync external content changes → Monaco model useEffect(() => { if (isModelDrivenChangeRef.current) { @@ -34,16 +45,6 @@ export function useMonacoSync({ } }, [content, typespecModel]); - // Use refs to avoid re-subscribing to onDidChangeContent on every keystroke - const contentRef = useRef(content); - const onContentChangeRef = useRef(onContentChange); - useEffect(() => { - contentRef.current = content; - }, [content]); - useEffect(() => { - onContentChangeRef.current = onContentChange; - }, [onContentChange]); - // Sync Monaco model changes → React state useEffect(() => { const disposable = typespecModel.onDidChangeContent(() => { diff --git a/packages/playground/test/use-monaco-sync.test.ts b/packages/playground/test/use-monaco-sync.test.ts index 23721e827ca..38cc84f8b39 100644 --- a/packages/playground/test/use-monaco-sync.test.ts +++ b/packages/playground/test/use-monaco-sync.test.ts @@ -9,6 +9,7 @@ function createMockModel(initialValue = "") { getValue: vi.fn(() => value), setValue: vi.fn((v: string) => { value = v; + for (const cb of listeners) cb(); }), onDidChangeContent: vi.fn((cb: () => void) => { listeners.push(cb); @@ -121,3 +122,33 @@ it("does not reset model after model-driven content change", () => { // Should NOT call setValue again since it was model-driven expect(model.setValue).not.toHaveBeenCalled(); }); + +it("does not report external content changes through a stale callback", () => { + const model = createMockModel("initial"); + const initialOnContentChange = vi.fn(); + const updatedOnContentChange = vi.fn(); + + const { rerender } = renderHook( + ({ content, onContentChange }) => + useMonacoSync({ + typespecModel: model as any, + content, + onContentChange, + }), + { + initialProps: { + content: "initial", + onContentChange: initialOnContentChange, + }, + }, + ); + + rerender({ + content: "sample content", + onContentChange: updatedOnContentChange, + }); + + expect(model.setValue).toHaveBeenCalledWith("sample content"); + expect(initialOnContentChange).not.toHaveBeenCalled(); + expect(updatedOnContentChange).not.toHaveBeenCalled(); +});