mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
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]>
269 lines
8.6 KiB
TypeScript
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)
|
|
})
|
|
})
|