Skip to content

Commit 2be771c

Browse files
Vasanthdev2004root
authored andcommitted
fix(theme): remove stale memo wrappers from theme context hooks (Gitlawb#534)
* fix(theme): remove stale React Compiler memo wrappers from theme hooks Rebase on current main (includes Gitlawb#589 reconciler fix). The React Compiler memo caches (_c) in useTheme() and usePreviewTheme() use referential equality checks on destructured context values. These caches can return stale references when the ThemeProvider's useMemo recreates the context value object but the individual property references (setThemeSetting, setPreviewTheme, etc.) compare equal — the memo short-circuits and returns a cached tuple/object that still holds the old closure captures. This is a distinct bug from Gitlawb#589 (which fixed the ink reconciler's commitUpdate path for host prop updates). Gitlawb#589 ensures that when React _does_ re-render a component with new props, those props actually reach the DOM node. But the memo wrappers here prevent React from _even seeing_ the new context value in the first place — the hook returns the stale cached result. Removing the memo wrappers ensures useTheme() and usePreviewTheme() always read the current context value, eliminating the stale-reference path entirely. * test(theme): add regression tests for useTheme()/usePreviewTheme() stale-value bug These tests verify that context hooks always return fresh values after ThemeProvider re-renders, even when React Compiler memo caches are in play. - useTheme() must reflect currentTheme changes immediately after setThemeSetting is called (not return a stale cached tuple). - usePreviewTheme() must return functional actions after context re-renders (not stale closures from before the theme change). On current main (with _c memo wrappers), these tests expose the bug: the memo cache compares setThemeSetting by reference (stable across renders via useMemo) and short-circuits, returning the old cached result with stale currentTheme. * fix(test): correct import paths for ThemeProvider.test.tsx Fix relative paths for ink.js, KeybindingSetup, AppStateProvider, useStdin mock, systemTheme mock, and config mock to account for the test file being in src/components/design-system/ rather than src/components/. * fix(test): rewrite ThemeProvider tests using Ink renderer Use Ink's createRoot instead of react-dom/client, matching the pattern from ThemePicker.test.tsx. The tests now render through Ink's terminal renderer and check frame output for theme values, which is the same environment ThemeProvider actually runs in. * fix(test): correct all relative import paths for design-system/ depth - ink.js, KeybindingSetup, AppStateProvider: ../ → ../../ - StructuredDiff: same pattern as ThemePicker test adjusted for depth --------- Co-authored-by: root <root@vm7508.lumadock.com>
1 parent f247669 commit 2be771c

2 files changed

Lines changed: 222 additions & 35 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* @file Regression tests for ThemeProvider context hooks.
3+
*
4+
* These tests verify that useTheme() and usePreviewTheme() always return
5+
* fresh values when the ThemeProvider context updates, even when the
6+
* React Compiler memo cache (_c) is in play.
7+
*
8+
* Bug: The React Compiler emits memo caches that compare individual
9+
* destructured context properties by referential equality. When
10+
* ThemeProvider's useMemo recreates the context value object (because
11+
* currentTheme changed), but some properties like setThemeSetting are
12+
* referentially stable across renders, the _c memo cache sees no change
13+
* and returns the stale cached result — a tuple/object still holding
14+
* the old currentTheme value.
15+
*
16+
* Fix: Remove the _c memo wrappers so useTheme()/usePreviewTheme()
17+
* always read the current context value directly.
18+
*/
19+
import { PassThrough } from 'node:stream'
20+
21+
import { afterEach, expect, mock, test } from 'bun:test'
22+
import React, { useEffect } from 'react'
23+
import stripAnsi from 'strip-ansi'
24+
25+
import { createRoot, Text, useTheme } from '../../ink.js'
26+
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
27+
import { AppStateProvider } from '../../state/AppState.js'
28+
import { ThemeProvider, usePreviewTheme } from './ThemeProvider.js'
29+
30+
mock.module('../StructuredDiff.js', () => ({
31+
StructuredDiff: function StructuredDiffPreview(): React.ReactNode {
32+
return <Text>diff</Text>
33+
},
34+
}))
35+
mock.module('../StructuredDiff/colorDiff.js', () => ({
36+
getColorModuleUnavailableReason: () => 'env',
37+
getSyntaxTheme: () => null,
38+
}))
39+
40+
const SYNC_START = '\x1B[?2026h'
41+
const SYNC_END = '\x1B[?2026l'
42+
43+
function extractLastFrame(output: string): string {
44+
let lastFrame: string | null = null
45+
let cursor = 0
46+
while (cursor < output.length) {
47+
const start = output.indexOf(SYNC_START, cursor)
48+
if (start === -1) break
49+
const contentStart = start + SYNC_START.length
50+
const end = output.indexOf(SYNC_END, contentStart)
51+
if (end === -1) break
52+
const frame = output.slice(contentStart, end)
53+
if (frame.trim().length > 0) lastFrame = frame
54+
cursor = end + SYNC_END.length
55+
}
56+
return lastFrame ?? output
57+
}
58+
59+
function createTestStreams() {
60+
let output = ''
61+
const stdout = new PassThrough()
62+
const stdin = new PassThrough() as PassThrough & {
63+
isTTY: boolean
64+
setRawMode: () => void
65+
ref: () => void
66+
unref: () => void
67+
}
68+
stdin.isTTY = true
69+
stdin.setRawMode = () => {}
70+
stdin.ref = () => {}
71+
stdin.unref = () => {}
72+
;(stdout as unknown as { columns: number }).columns = 120
73+
stdout.on('data', chunk => { output += chunk.toString() })
74+
return { stdout, stdin, getOutput: () => output }
75+
}
76+
77+
async function waitForCondition(
78+
predicate: () => boolean,
79+
timeoutMs = 3000,
80+
): Promise<void> {
81+
const startedAt = Date.now()
82+
while (Date.now() - startedAt < timeoutMs) {
83+
if (predicate()) return
84+
await Bun.sleep(10)
85+
}
86+
throw new Error('Timed out waiting for condition')
87+
}
88+
89+
async function waitForFrame(
90+
getOutput: () => string,
91+
predicate: (frame: string) => boolean,
92+
): Promise<string> {
93+
let frame = ''
94+
await waitForCondition(() => {
95+
frame = stripAnsi(extractLastFrame(getOutput()))
96+
return predicate(frame)
97+
})
98+
return frame
99+
}
100+
101+
afterEach(() => {
102+
mock.restore()
103+
})
104+
105+
/**
106+
* Verifies that useTheme() returns the current theme value immediately
107+
* after setThemeSetting changes it, not a stale cached value.
108+
*
109+
* With React Compiler memo caches, the hook could return [oldTheme, setter]
110+
* because the memo compared setThemeSetting by reference (stable across
111+
* renders) and short-circuited, missing the currentTheme change.
112+
*/
113+
test('useTheme() reflects updated currentTheme after setThemeSetting call', async () => {
114+
const { stdout, stdin, getOutput } = createTestStreams()
115+
const root = await createRoot({
116+
stdout: stdout as unknown as NodeJS.WriteStream,
117+
stdin: stdin as unknown as NodeJS.ReadStream,
118+
patchConsole: false,
119+
})
120+
121+
function ThemeDisplay() {
122+
const [theme] = useTheme()
123+
return <Text>current:{theme}</Text>
124+
}
125+
126+
let setThemeFn: ((s: string) => void) | null = null
127+
function ThemeSetter() {
128+
const [, setter] = useTheme()
129+
useEffect(() => { setThemeFn = setter })
130+
return null
131+
}
132+
133+
root.render(
134+
<AppStateProvider>
135+
<KeybindingSetup>
136+
<ThemeProvider initialState="dark">
137+
<ThemeDisplay />
138+
<ThemeSetter />
139+
</ThemeProvider>
140+
</KeybindingSetup>
141+
</AppStateProvider>,
142+
)
143+
144+
try {
145+
// Initial render
146+
const initial = await waitForFrame(getOutput, f => f.includes('current:dark'))
147+
expect(initial).toContain('current:dark')
148+
149+
// Change theme — useTheme() must reflect the new value
150+
setThemeFn!('light')
151+
const afterLight = await waitForFrame(getOutput, f => f.includes('current:light'))
152+
expect(afterLight).toContain('current:light')
153+
154+
// Change again to confirm no stale caching
155+
setThemeFn!('ansi')
156+
const afterAnsi = await waitForFrame(getOutput, f => f.includes('current:ansi'))
157+
expect(afterAnsi).toContain('current:ansi')
158+
} finally {
159+
root.unmount()
160+
stdin.end()
161+
stdout.end()
162+
await Bun.sleep(0)
163+
}
164+
})
165+
166+
/**
167+
* Verifies that usePreviewTheme() returns functional action references
168+
* after the ThemeProvider context value is recreated on theme change.
169+
*/
170+
test('usePreviewTheme() setPreviewTheme changes displayed theme', async () => {
171+
const { stdout, stdin, getOutput } = createTestStreams()
172+
const root = await createRoot({
173+
stdout: stdout as unknown as NodeJS.WriteStream,
174+
stdin: stdin as unknown as NodeJS.ReadStream,
175+
patchConsole: false,
176+
})
177+
178+
let previewActions: ReturnType<typeof usePreviewTheme> | null = null
179+
180+
function ThemeDisplay() {
181+
const [theme] = useTheme()
182+
const actions = usePreviewTheme()
183+
useEffect(() => { previewActions = actions })
184+
return <Text>current:{theme}</Text>
185+
}
186+
187+
root.render(
188+
<AppStateProvider>
189+
<KeybindingSetup>
190+
<ThemeProvider initialState="dark">
191+
<ThemeDisplay />
192+
</ThemeProvider>
193+
</KeybindingSetup>
194+
</AppStateProvider>,
195+
)
196+
197+
try {
198+
// Initial render
199+
await waitForFrame(getOutput, f => f.includes('current:dark'))
200+
201+
// setPreviewTheme should change the displayed theme
202+
previewActions!.setPreviewTheme('light')
203+
const afterPreview = await waitForFrame(getOutput, f => f.includes('current:light'))
204+
expect(afterPreview).toContain('current:light')
205+
206+
// cancelPreview should revert to the saved setting
207+
previewActions!.cancelPreview()
208+
const afterCancel = await waitForFrame(getOutput, f => f.includes('current:dark'))
209+
expect(afterCancel).toContain('current:dark')
210+
} finally {
211+
root.unmount()
212+
stdin.end()
213+
stdout.end()
214+
await Bun.sleep(0)
215+
}
216+
})

src/components/design-system/ThemeProvider.tsx

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { c as _c } from "react-compiler-runtime";
21
import { feature } from 'bun:bundle';
32
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
43
import useStdin from '../../ink/hooks/use-stdin.js';
@@ -120,21 +119,8 @@ export function ThemeProvider({
120119
* accepts any ThemeSetting (including 'auto').
121120
*/
122121
export function useTheme() {
123-
const $ = _c(3);
124-
const {
125-
currentTheme,
126-
setThemeSetting
127-
} = useContext(ThemeContext);
128-
let t0;
129-
if ($[0] !== currentTheme || $[1] !== setThemeSetting) {
130-
t0 = [currentTheme, setThemeSetting];
131-
$[0] = currentTheme;
132-
$[1] = setThemeSetting;
133-
$[2] = t0;
134-
} else {
135-
t0 = $[2];
136-
}
137-
return t0;
122+
const { currentTheme, setThemeSetting } = useContext(ThemeContext);
123+
return [currentTheme, setThemeSetting] as const;
138124
}
139125

140126
/**
@@ -145,25 +131,10 @@ export function useThemeSetting() {
145131
return useContext(ThemeContext).themeSetting;
146132
}
147133
export function usePreviewTheme() {
148-
const $ = _c(4);
149-
const {
134+
const { setPreviewTheme, savePreview, cancelPreview } = useContext(ThemeContext);
135+
return {
150136
setPreviewTheme,
151137
savePreview,
152-
cancelPreview
153-
} = useContext(ThemeContext);
154-
let t0;
155-
if ($[0] !== cancelPreview || $[1] !== savePreview || $[2] !== setPreviewTheme) {
156-
t0 = {
157-
setPreviewTheme,
158-
savePreview,
159-
cancelPreview
160-
};
161-
$[0] = cancelPreview;
162-
$[1] = savePreview;
163-
$[2] = setPreviewTheme;
164-
$[3] = t0;
165-
} else {
166-
t0 = $[3];
167-
}
168-
return t0;
138+
cancelPreview,
139+
};
169140
}

0 commit comments

Comments
 (0)