Files
gui/pkgs/core/core-test/themeMediaOverRender.web.test.tsx
zeekayandHanzo Dev 69c6e860d7 transplant onto tamagui v2.6.0, brand as a re-runnable transform
This repo could never sync. There was NO merge-base with tamagui — 372 commits
here against 15,791 there, unrelated histories — because it began as a
copy-paste rebrand with a flattened layout. Every "sync" since was a hand
re-application that drifted, which is why the tree sat on 7.3.1 while upstream
shipped fixes we then rediscovered by hand.

This commit's PARENT IS TAMAGUI. A future sync is `git merge tama/main` then
`node scripts/rebrand.mjs` — a real merge, because the history is now shared.

174 packages: 166 upstream, our 8 restored (chrome, shell, telemetry, recipes,
get, font-geist-{sans,mono}, @hanzo/booker), bento-get gained. 170/170 build.

Our patches to files UPSTREAM ALSO OWNS are carried, which the package-level
delta missed and which was where the value actually was:
  - useMedia's server snapshot (`initState ?? getMedia()`). Upstream still
    returns undefined there, so every Tamagui Dialog kills a prerender — the
    exact crash hanzo.app spent a day on.
  - data-slot on web host nodes, absent upstream entirely.
Both applied ONTO upstream's newer files (it rewrote useMedia +150/-63 and
createComponent +160/-55), marked `PATCH (ours, carried across the transplant)`.

Seven traps are encoded in scripts/rebrand.mjs, each found by a gate:
skip itself and lockfiles; special-case the double-branded names in BOTH scoped
and bare form; map path strings, not just paths; drop a level for the flattened
group; check disk rather than a stale index.

Rust build artifacts are NOT committed. An earlier draft of this commit swept
apps/desktop/src-tauri/target/debug in via `git add -A` — 3,859 files including
a 272MB .a, over GitHub's 100MB cap, so the push could never have landed. They
are gitignored now.

Co-Authored-By: Hanzo Dev <[email protected]>
2026-07-30 18:25:06 -07:00

269 lines
8.6 KiB
TypeScript

// Guards the granular re-render contract:
//
// - Components that read theme tokens (via $-prefixed style props or
// useTheme().val) MUST re-render when the theme they depend on changes.
// - Components that DO NOT read theme tokens MUST NOT re-render on a theme
// change. (the regression that motivated this test: a moonshot that
// dropped the per-component useSyncExternalStore in favour of putting
// ThemeState directly into ThemeStateContext caused every consumer to
// re-render on any theme update, killing toggle perf.)
//
// Same shape for media: components that don't touch a $-prefixed media key
// must not re-render when the media state changes.
process.env.GUI_TARGET = 'web'
import { getDefaultGuiConfig } from '@hanzogui/config-default'
import {
GuiProvider,
Theme,
createGui,
setMediaState,
updateMediaListeners,
useMedia,
useTheme,
} from '@hanzogui/core'
import { act, render } from '@testing-library/react'
import { memo, useState } from 'react'
import { describe, expect, test } from 'vitest'
const conf = createGui(getDefaultGuiConfig())
type Counter = { current: number }
// memo wraps so a parent JSX re-render alone doesn't bump count.
// Only context/subscription-driven re-renders should count.
const ThemeReader = memo(function ThemeReader({ counter }: { counter: Counter }) {
counter.current += 1
const theme = useTheme()
// read .val so getThemeProxied tracks 'background' in keys.current
const bg = theme?.background?.val
return <span style={{ background: String(bg) }} />
})
const StaticReader = memo(function StaticReader({ counter }: { counter: Counter }) {
counter.current += 1
// no theme access — keys.current stays empty
return <span style={{ background: 'red' }} />
})
const MediaReader = memo(function MediaReader({ counter }: { counter: Counter }) {
counter.current += 1
const media = useMedia()
// touch the sm key to populate keys
const isSm = media.sm
return <span data-sm={String(isSm)} />
})
describe('theme/media over-render guard', () => {
test('theme change re-renders theme readers; static readers stay put', () => {
const themeReader = { current: 0 }
const staticReader = { current: 0 }
let setName: (n: 'light' | 'dark') => void = () => {}
function ThemedSubtree() {
const [name, setN] = useState<'light' | 'dark'>('light')
setName = setN
return (
<Theme name={name}>
<ThemeReader counter={themeReader} />
<StaticReader counter={staticReader} />
</Theme>
)
}
render(
<GuiProvider config={conf} defaultTheme="light">
<ThemedSubtree />
</GuiProvider>
)
const themeBaseline = themeReader.current
const staticBaseline = staticReader.current
act(() => {
setName('dark')
})
expect(themeReader.current).toBeGreaterThan(themeBaseline)
// CRITICAL: static reader must NOT re-render on a theme change it doesn't depend on
expect(staticReader.current).toBe(staticBaseline)
})
test('media change re-renders media readers; static readers stay put', () => {
const mediaReader = { current: 0 }
const staticReader = { current: 0 }
render(
<GuiProvider config={conf} defaultTheme="light">
<MediaReader counter={mediaReader} />
<StaticReader counter={staticReader} />
</GuiProvider>
)
const mediaBaseline = mediaReader.current
const staticBaseline = staticReader.current
act(() => {
// mediaState tracks boolean active flags per key (not the query config)
setMediaState({ sm: true, md: false, lg: false, xl: false, xxl: false } as any)
updateMediaListeners()
})
expect(mediaReader.current).toBeGreaterThan(mediaBaseline)
// CRITICAL: static reader must NOT re-render on a media change it doesn't depend on
expect(staticReader.current).toBe(staticBaseline)
})
})
// always calls useTheme (rules of hooks), but only READS a token when asked.
// keys.current stays empty until readTheme flips true, so the subscription must
// be established lazily (the effect re-evaluates shouldSubscribeToTheme every render).
const ConditionalReader = memo(function ConditionalReader({
counter,
readTheme,
}: {
counter: Counter
readTheme: boolean
}) {
counter.current += 1
const theme = useTheme()
const bg = readTheme ? theme?.background?.val : 'red'
return <span style={{ background: String(bg) }} />
})
// re-renders whenever `tick` changes (parent-driven), exercising the
// effect cleanup→setup churn that the renderVersion final-unmount guard sits on.
const TickThemeReader = memo(function TickThemeReader({
counter,
tick,
}: {
counter: Counter
tick: number
}) {
counter.current += 1
const theme = useTheme()
const bg = theme?.background?.val
return <span data-tick={tick} style={{ background: String(bg) }} />
})
describe('theme subscription lifecycle', () => {
// guards the lazy-subscribe path: a component that does not read theme tokens
// must NOT subscribe, but once it begins reading them it MUST start updating.
test('a component that begins reading theme mid-life starts subscribing', () => {
const counter = { current: 0 }
let setName: (n: 'light' | 'dark') => void = () => {}
function App({ readTheme }: { readTheme: boolean }) {
const [name, setN] = useState<'light' | 'dark'>('light')
setName = setN
return (
<Theme name={name}>
<ConditionalReader counter={counter} readTheme={readTheme} />
</Theme>
)
}
const { rerender } = render(
<GuiProvider config={conf} defaultTheme="light">
<App readTheme={false} />
</GuiProvider>
)
// not reading theme yet → a theme change must NOT re-render it
const beforeRead = counter.current
act(() => setName('dark'))
expect(counter.current).toBe(beforeRead)
// flip to reading theme: prop change re-renders once, populates keys,
// effect subscribes
act(() => {
rerender(
<GuiProvider config={conf} defaultTheme="light">
<App readTheme={true} />
</GuiProvider>
)
})
const afterRead = counter.current
// now a theme change MUST re-render it
act(() => setName('light'))
expect(counter.current).toBeGreaterThan(afterRead)
})
// guards the renderVersion final-unmount guard: parent-driven re-renders run
// effect cleanup before each new effect; that cleanup must NOT tear down the
// still-mounted subscription, so theme updates keep arriving after the churn.
test('a theme reader survives parent re-render churn and still updates on theme change', () => {
const counter = { current: 0 }
let setName: (n: 'light' | 'dark') => void = () => {}
let bump: () => void = () => {}
function App() {
const [name, setN] = useState<'light' | 'dark'>('light')
const [tick, setTick] = useState(0)
setName = setN
bump = () => setTick((t) => t + 1)
return (
<Theme name={name}>
<TickThemeReader counter={counter} tick={tick} />
</Theme>
)
}
render(
<GuiProvider config={conf} defaultTheme="light">
<App />
</GuiProvider>
)
// churn: 3 parent-driven re-renders, each running the no-deps effect's
// cleanup→setup. the subscription must persist across all of them.
act(() => {
bump()
bump()
bump()
})
const afterChurn = counter.current
act(() => setName('dark'))
// subscription survived the churn → theme change re-renders it
expect(counter.current).toBeGreaterThan(afterChurn)
})
// guards final-unmount cleanup: unmounting one reader must run its cleanup
// without disturbing a sibling's still-live subscription.
test('unmounting a theme reader does not break a sibling subscription', () => {
const a = { current: 0 }
const b = { current: 0 }
let setName: (n: 'light' | 'dark') => void = () => {}
let setShowA: (v: boolean) => void = () => {}
function App() {
const [name, setN] = useState<'light' | 'dark'>('light')
const [showA, setSA] = useState(true)
setName = setN
setShowA = setSA
return (
<Theme name={name}>
{showA ? <ThemeReader counter={a} /> : null}
<ThemeReader counter={b} />
</Theme>
)
}
render(
<GuiProvider config={conf} defaultTheme="light">
<App />
</GuiProvider>
)
// unmount A → its effect's final cleanup runs (renderVersion matches)
act(() => setShowA(false))
const bBeforeChange = b.current
act(() => setName('dark'))
// sibling B's subscription is untouched → it still updates
expect(b.current).toBeGreaterThan(bBeforeChange)
})
})