@chronogrove/ui 0.79.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +40 -19
  2. package/package.json +73 -6
  3. package/src/__snapshots__/header.spec.js.snap +8 -8
  4. package/src/__snapshots__/theme.spec.js.snap +39 -20
  5. package/src/action-button.js +6 -6
  6. package/src/action-button.spec.js +14 -2
  7. package/src/action-card-layout.js +13 -0
  8. package/src/action-card-layout.spec.js +13 -0
  9. package/src/animated-page-background/ChronogroveAnimatedPageBackground.js +153 -0
  10. package/src/animated-page-background/ChronogroveAnimatedPageBackground.spec.js +189 -0
  11. package/src/animated-page-background/ColorBends.js +309 -0
  12. package/src/animated-page-background/color-bends.css +13 -0
  13. package/src/animated-page-background/index.js +2 -0
  14. package/src/animated-page-background/index.spec.js +18 -0
  15. package/src/button.js +4 -3
  16. package/src/category-label.js +23 -0
  17. package/src/category-label.spec.js +24 -0
  18. package/src/chevron-icons.js +37 -0
  19. package/src/chronogrove-theme-surface-colors.js +22 -0
  20. package/src/color-mode/browser-sync.js +7 -0
  21. package/src/color-mode/browser-sync.spec.js +7 -0
  22. package/src/color-mode/chronogrove-head-theme.js +22 -0
  23. package/src/color-mode/head-inline.js +40 -5
  24. package/src/color-mode/head-inline.spec.js +29 -0
  25. package/src/color-mode/index.js +3 -0
  26. package/src/color-mode/resolve-theme-colors.js +18 -6
  27. package/src/color-mode/resolve-theme-colors.spec.js +13 -3
  28. package/src/color-mode/spa-navigation.js +14 -0
  29. package/src/color-mode/spa-navigation.spec.js +25 -0
  30. package/src/color-mode/use-document-color-mode-surface.js +52 -0
  31. package/src/color-mode/use-document-color-mode-surface.node.spec.js +12 -0
  32. package/src/color-mode/use-document-color-mode-surface.spec.js +154 -0
  33. package/src/color-toggle-styles.css +10 -0
  34. package/src/color-toggle.js +11 -2
  35. package/src/emotion-cache.node.spec.js +13 -0
  36. package/src/emotion-cache.spec.js +12 -0
  37. package/src/external-link-icon.js +30 -0
  38. package/src/external-link-icon.spec.js +16 -0
  39. package/src/gatsby/build-theme-ui-color-mode-head-components.js +7 -1
  40. package/src/gatsby/index.spec.js +42 -0
  41. package/src/gatsby/on-route-update-color-mode.js +1 -14
  42. package/src/header.js +4 -16
  43. package/src/lazy-load.js +30 -11
  44. package/src/lazy-load.spec.js +9 -5
  45. package/src/metric-badge.js +10 -0
  46. package/src/metric-badge.spec.js +15 -0
  47. package/src/metric-card.js +95 -0
  48. package/src/metric-card.spec.js +60 -0
  49. package/src/muted-card-footer.js +22 -0
  50. package/src/muted-card-footer.spec.js +25 -0
  51. package/src/next/app-shell.js +34 -0
  52. package/src/next/emotion-registry.js +68 -0
  53. package/src/next/emotion-registry.spec.js +99 -0
  54. package/src/next/index.js +4 -0
  55. package/src/next/root-layout-head.js +42 -0
  56. package/src/next/root-layout-head.spec.js +17 -0
  57. package/src/next/theme-ui-color-mode-route-sync.js +32 -0
  58. package/src/page-backdrop.js +42 -0
  59. package/src/page-backdrop.spec.js +41 -0
  60. package/src/pagination-button.js +4 -4
  61. package/src/pagination-button.spec.js +26 -2
  62. package/src/pagination.js +198 -0
  63. package/src/pagination.spec.js +281 -0
  64. package/src/skip-nav/SkipNavLink.js +6 -5
  65. package/src/skip-nav/SkipNavLink.spec.js +11 -0
  66. package/src/status-card.js +18 -0
  67. package/src/status-card.spec.js +38 -0
  68. package/src/theme.js +27 -20
  69. package/src/widget-call-to-action.js +106 -0
  70. package/src/widget-call-to-action.spec.js +115 -0
  71. package/src/widget-section.js +83 -0
  72. package/src/widget-section.spec.js +59 -0
@@ -40,6 +40,8 @@ describe('@chronogrove/ui/gatsby', () => {
40
40
  const fallbackStyle = fallbackStyleContainer.querySelector('style')
41
41
  expect(fallbackStyle).toHaveTextContent(/:root\[data-theme-ui-color-mode="default"\]/)
42
42
  expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-text: #111 !important/)
43
+ expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-background:/)
44
+ expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-text:/)
43
45
  })
44
46
  })
45
47
 
@@ -100,6 +102,46 @@ describe('@chronogrove/ui/gatsby', () => {
100
102
  onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
101
103
  expect(replaceHeadComponents.mock.calls[0][0].map(c => c.key)).toEqual(['html-bg-color', 'theme-ui-no-flash'])
102
104
  })
105
+
106
+ it('treats missing component keys as empty string', () => {
107
+ const getHeadComponents = jest.fn(() => [{ type: 'meta' }, { key: 'theme-ui-no-flash', type: 'script' }])
108
+ const replaceHeadComponents = jest.fn()
109
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
110
+ const sorted = replaceHeadComponents.mock.calls[0][0]
111
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
112
+ })
113
+
114
+ it('handles null or undefined head entries when sorting', () => {
115
+ const getHeadComponents = jest.fn(() => [
116
+ null,
117
+ undefined,
118
+ { key: 'theme-ui-no-flash', type: 'script' },
119
+ { key: 'other', type: 'meta' }
120
+ ])
121
+ const replaceHeadComponents = jest.fn()
122
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
123
+ const sorted = replaceHeadComponents.mock.calls[0][0]
124
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
125
+ })
126
+
127
+ it('treats explicit null keys like missing keys', () => {
128
+ const getHeadComponents = jest.fn(() => [
129
+ { key: null, type: 'meta' },
130
+ { key: 'theme-ui-no-flash', type: 'script' }
131
+ ])
132
+ const replaceHeadComponents = jest.fn()
133
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
134
+ const sorted = replaceHeadComponents.mock.calls[0][0]
135
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
136
+ })
137
+
138
+ it('sorts when the right-hand entry is null', () => {
139
+ const getHeadComponents = jest.fn(() => [{ key: 'theme-ui-no-flash', type: 'script' }, null])
140
+ const replaceHeadComponents = jest.fn()
141
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
142
+ const sorted = replaceHeadComponents.mock.calls[0][0]
143
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
144
+ })
103
145
  })
104
146
 
105
147
  describe('onRouteUpdateThemeUiColorMode', () => {
@@ -1,14 +1 @@
1
- import { RECONCILE_COLOR_MODE_EVENT } from '../color-mode/constants.js'
2
- import { scheduleThemeUiColorModeSync } from '../color-mode/browser-sync.js'
3
-
4
- /**
5
- * Call from Gatsby `onRouteUpdate` so Theme UI color mode stays aligned with `localStorage` and the
6
- * document after client-side navigations. Dispatches {@link RECONCILE_COLOR_MODE_EVENT} for app code
7
- * that listens (e.g. React context reconciliation).
8
- */
9
- export function onRouteUpdateThemeUiColorMode() {
10
- scheduleThemeUiColorModeSync()
11
- if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') {
12
- window.dispatchEvent(new window.CustomEvent(RECONCILE_COLOR_MODE_EVENT))
13
- }
14
- }
1
+ export { reconcileThemeUiColorModeOnNavigation as onRouteUpdateThemeUiColorMode } from '../color-mode/spa-navigation.js'
package/src/header.js CHANGED
@@ -1,5 +1,4 @@
1
- /** @jsx jsx */
2
- import { jsx } from 'theme-ui'
1
+ import { Box } from '@theme-ui/components'
3
2
 
4
3
  /**
5
4
  * Header
@@ -8,20 +7,9 @@ import { jsx } from 'theme-ui'
8
7
  */
9
8
  const Header = ({ children, styles }) => {
10
9
  return (
11
- <header
12
- role='banner'
13
- sx={{
14
- variant: 'styles.Header'
15
- }}
16
- >
17
- <div
18
- sx={{
19
- ...(styles ? styles : {})
20
- }}
21
- >
22
- {children}
23
- </div>
24
- </header>
10
+ <Box as='header' role='banner' sx={{ variant: 'styles.Header' }}>
11
+ <Box sx={{ ...(styles ? styles : {}) }}>{children}</Box>
12
+ </Box>
25
13
  )
26
14
  }
27
15
 
package/src/lazy-load.js CHANGED
@@ -1,10 +1,9 @@
1
- /** @jsx jsx */
2
- import { jsx } from 'theme-ui'
3
1
  import { useState, useEffect } from 'react'
2
+ import { Box } from '@theme-ui/components'
4
3
  import { useInView } from 'react-intersection-observer'
5
4
 
6
5
  const DefaultPlaceholder = ({ height = '100%', width = '100%' }) => (
7
- <div
6
+ <Box
8
7
  data-testid='default-placeholder'
9
8
  sx={{
10
9
  minHeight: '1px',
@@ -14,28 +13,48 @@ const DefaultPlaceholder = ({ height = '100%', width = '100%' }) => (
14
13
  }}
15
14
  >
16
15
  {' '}
17
- </div>
16
+ </Box>
18
17
  )
19
18
 
20
19
  /**
21
20
  * Lazy Loader
22
21
  *
23
22
  * Hides a component until it's been visible in the viewport.
23
+ *
24
+ * IntersectionObserver does not run on the server; children are never included in SSR HTML.
25
+ * On the client, observation is deferred until after mount so Next.js (and other SSR
26
+ * frameworks) hydrate the placeholder first, then attach the observer — avoiding a
27
+ * first-paint flash of real content when the block is already in view.
28
+ *
29
+ * @param {object} [props.useInViewOptions] -
30
+ * Passed to `useInView` after defaults (`triggerOnce: true`, `threshold: 0`,
31
+ * `initialInView: false`). Use `rootMargin` or `threshold` for stricter visibility.
32
+ * Pass `skip` to disable observation (merged with the internal client-only `skip`).
24
33
  */
25
- const LazyLoad = ({ children, placeholder = <DefaultPlaceholder /> }) => {
26
- const [hasBeenVisible, setHasBeenVisible] = useState(false)
34
+ const LazyLoad = ({ children, placeholder = <DefaultPlaceholder />, useInViewOptions = {} }) => {
35
+ const [mounted, setMounted] = useState(false)
36
+ const [revealed, setRevealed] = useState(false)
37
+ const { skip: skipFromOptions, ...restInViewOptions } = useInViewOptions
38
+
27
39
  const { ref, inView } = useInView({
28
40
  triggerOnce: true,
29
- threshold: 0
41
+ threshold: 0,
42
+ initialInView: false,
43
+ ...restInViewOptions,
44
+ skip: !mounted || !!skipFromOptions
30
45
  })
31
46
 
32
47
  useEffect(() => {
33
- if (inView && !hasBeenVisible) {
34
- setHasBeenVisible(true)
48
+ setMounted(true)
49
+ }, [])
50
+
51
+ useEffect(() => {
52
+ if (mounted && inView) {
53
+ setRevealed(true)
35
54
  }
36
- }, [inView, hasBeenVisible])
55
+ }, [mounted, inView])
37
56
 
38
- return <div ref={ref}>{hasBeenVisible ? children : placeholder}</div>
57
+ return <Box ref={ref}>{revealed ? children : placeholder}</Box>
39
58
  }
40
59
 
41
60
  export default LazyLoad
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render, screen } from '@testing-library/react'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
3
  import '@testing-library/jest-dom'
4
4
  import LazyLoad from './lazy-load.js'
5
5
 
@@ -31,7 +31,7 @@ describe('LazyLoad', () => {
31
31
  expect(screen.queryByTestId('content')).not.toBeInTheDocument()
32
32
  })
33
33
 
34
- it('renders the children when visible', () => {
34
+ it('renders the children when visible', async () => {
35
35
  mockInView = true
36
36
 
37
37
  render(
@@ -40,7 +40,9 @@ describe('LazyLoad', () => {
40
40
  </LazyLoad>
41
41
  )
42
42
 
43
- expect(screen.getByTestId('content')).toBeInTheDocument()
43
+ await waitFor(() => {
44
+ expect(screen.getByTestId('content')).toBeInTheDocument()
45
+ })
44
46
  })
45
47
 
46
48
  it('renders the custom placeholder when provided', () => {
@@ -54,7 +56,7 @@ describe('LazyLoad', () => {
54
56
  expect(screen.queryByTestId('content')).not.toBeInTheDocument()
55
57
  })
56
58
 
57
- it('does not re-render children if already visible', () => {
59
+ it('does not re-render children if already visible', async () => {
58
60
  mockInView = true
59
61
 
60
62
  const { rerender } = render(
@@ -63,7 +65,9 @@ describe('LazyLoad', () => {
63
65
  </LazyLoad>
64
66
  )
65
67
 
66
- expect(screen.getByTestId('content')).toBeInTheDocument()
68
+ await waitFor(() => {
69
+ expect(screen.getByTestId('content')).toBeInTheDocument()
70
+ })
67
71
 
68
72
  mockInView = false
69
73
 
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import { Badge } from '@theme-ui/components'
3
+
4
+ const MetricBadge = ({ children, ...props }) => (
5
+ <Badge mr={3} {...props}>
6
+ {children}
7
+ </Badge>
8
+ )
9
+
10
+ export default MetricBadge
@@ -0,0 +1,15 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import MetricBadge from './metric-badge.js'
5
+
6
+ describe('MetricBadge', () => {
7
+ it('renders children', () => {
8
+ render(
9
+ <ThemeUIProvider theme={{}}>
10
+ <MetricBadge>42</MetricBadge>
11
+ </ThemeUIProvider>
12
+ )
13
+ expect(screen.getByText('42')).toBeInTheDocument()
14
+ })
15
+ })
@@ -0,0 +1,95 @@
1
+ import React from 'react'
2
+ import { Box, Card, Text } from '@theme-ui/components'
3
+ import { useThemeUI } from 'theme-ui'
4
+
5
+ import isDarkMode from './helpers/isDarkMode.js'
6
+
7
+ /**
8
+ * Metric summary card (e.g. Goodreads profile metrics). Uses `metricCard` / `metricCardDark` card variants.
9
+ * When `loading` is true, renders `loadingSlot` or a lightweight pulse placeholder (no react-placeholder dependency).
10
+ */
11
+ const MetricCard = ({ title, value, loading = false, loadingSlot, sx, ...props }) => {
12
+ const { colorMode } = useThemeUI()
13
+ const variant = isDarkMode(colorMode) ? 'metricCardDark' : 'metricCard'
14
+
15
+ const body = loading ? (
16
+ (loadingSlot ?? (
17
+ <Box
18
+ aria-busy='true'
19
+ role='status'
20
+ sx={{
21
+ minHeight: '3rem',
22
+ borderRadius: 'default',
23
+ bg: 'muted',
24
+ opacity: 0.7,
25
+ animation: 'cgPulse 1.2s ease-in-out infinite',
26
+ '@keyframes cgPulse': {
27
+ '0%, 100%': { opacity: 0.45 },
28
+ '50%': { opacity: 0.85 }
29
+ }
30
+ }}
31
+ />
32
+ ))
33
+ ) : (
34
+ <Box
35
+ sx={{
36
+ display: 'flex',
37
+ flexDirection: 'column',
38
+ gap: 1,
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ textAlign: 'center',
42
+ minWidth: 0,
43
+ width: '100%'
44
+ }}
45
+ >
46
+ <Text
47
+ as='span'
48
+ sx={{
49
+ fontFamily: 'heading',
50
+ fontWeight: 'bold',
51
+ fontSize: [4, 5],
52
+ lineHeight: 1.1,
53
+ color: 'text',
54
+ m: 0,
55
+ letterSpacing: '-0.02em'
56
+ }}
57
+ >
58
+ {value}
59
+ </Text>
60
+ <Text
61
+ as='span'
62
+ sx={{
63
+ fontSize: 0,
64
+ color: 'textMuted',
65
+ lineHeight: 1.35,
66
+ m: 0,
67
+ textTransform: 'uppercase',
68
+ letterSpacing: '0.06em',
69
+ fontWeight: 'medium'
70
+ }}
71
+ >
72
+ {title}
73
+ </Text>
74
+ </Box>
75
+ )
76
+
77
+ return (
78
+ <Card
79
+ variant={variant}
80
+ sx={{
81
+ display: 'flex',
82
+ alignItems: 'stretch',
83
+ justifyContent: 'center',
84
+ minHeight: '6rem',
85
+ py: 3,
86
+ ...sx
87
+ }}
88
+ {...props}
89
+ >
90
+ {body}
91
+ </Card>
92
+ )
93
+ }
94
+
95
+ export default MetricCard
@@ -0,0 +1,60 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import MetricCard from './metric-card.js'
5
+
6
+ jest.mock('theme-ui', () => {
7
+ const actual = jest.requireActual('theme-ui')
8
+ return {
9
+ ...actual,
10
+ useThemeUI: jest.fn(() => ({ colorMode: 'default' }))
11
+ }
12
+ })
13
+
14
+ const { useThemeUI } = require('theme-ui')
15
+
16
+ const theme = { colors: { modes: { dark: {} } } }
17
+
18
+ describe('MetricCard', () => {
19
+ beforeEach(() => {
20
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
21
+ })
22
+
23
+ it('renders value and title when not loading', () => {
24
+ render(
25
+ <ThemeUIProvider theme={theme}>
26
+ <MetricCard title='Followers' value='12' />
27
+ </ThemeUIProvider>
28
+ )
29
+ expect(screen.getByText('12')).toBeInTheDocument()
30
+ expect(screen.getByText('Followers')).toBeInTheDocument()
31
+ })
32
+
33
+ it('shows default loading placeholder when loading', () => {
34
+ render(
35
+ <ThemeUIProvider theme={theme}>
36
+ <MetricCard title='F' value='1' loading />
37
+ </ThemeUIProvider>
38
+ )
39
+ expect(screen.getByRole('status')).toBeInTheDocument()
40
+ })
41
+
42
+ it('uses metricCardDark when dark', () => {
43
+ useThemeUI.mockReturnValue({ colorMode: 'dark' })
44
+ render(
45
+ <ThemeUIProvider theme={theme}>
46
+ <MetricCard title='T' value='v' loading={false} />
47
+ </ThemeUIProvider>
48
+ )
49
+ expect(screen.getByText('v')).toBeInTheDocument()
50
+ })
51
+
52
+ it('uses custom loadingSlot', () => {
53
+ render(
54
+ <ThemeUIProvider theme={theme}>
55
+ <MetricCard title='t' value='v' loading loadingSlot={<span data-testid='slot'>wait</span>} />
56
+ </ThemeUIProvider>
57
+ )
58
+ expect(screen.getByTestId('slot')).toBeInTheDocument()
59
+ })
60
+ })
@@ -0,0 +1,22 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ /**
5
+ * Muted footer row for dashboard-style cards. Uses `styles.mutedCardFooter` on the theme.
6
+ */
7
+ const MutedCardFooter = ({ children, customStyles, ...props }) => (
8
+ <Box
9
+ sx={{
10
+ variant: 'styles.mutedCardFooter',
11
+ color: 'textMuted',
12
+ fontFamily: 'sans',
13
+ fontSize: 1,
14
+ ...(typeof customStyles === 'object' && customStyles !== null ? customStyles : {})
15
+ }}
16
+ {...props}
17
+ >
18
+ {children}
19
+ </Box>
20
+ )
21
+
22
+ export default MutedCardFooter
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import MutedCardFooter from './muted-card-footer.js'
5
+
6
+ const theme = { styles: { mutedCardFooter: { mt: 2 } } }
7
+
8
+ const wrap = ui => render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
9
+
10
+ describe('MutedCardFooter', () => {
11
+ it('renders children', () => {
12
+ wrap(<MutedCardFooter>Footer text</MutedCardFooter>)
13
+ expect(screen.getByText('Footer text')).toBeInTheDocument()
14
+ })
15
+
16
+ it('merges customStyles when an object', () => {
17
+ wrap(<MutedCardFooter customStyles={{ mt: 4 }}>A</MutedCardFooter>)
18
+ expect(screen.getByText('A')).toBeInTheDocument()
19
+ })
20
+
21
+ it('ignores non-object customStyles', () => {
22
+ wrap(<MutedCardFooter customStyles='invalid'>B</MutedCardFooter>)
23
+ expect(screen.getByText('B')).toBeInTheDocument()
24
+ })
25
+ })
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ import { Box } from '@theme-ui/components'
4
+
5
+ import { ChronogroveAnimatedPageBackground } from '../animated-page-background/index.js'
6
+ import { useDocumentColorModeSurface } from '../color-mode/index.js'
7
+ import { ChronogroveThemeProvider } from '../provider.js'
8
+ import chronogroveTheme from '../theme.js'
9
+
10
+ import { ChronogroveNextThemeUiColorModeRouteSync } from './theme-ui-color-mode-route-sync.js'
11
+
12
+ /** Mirrors Gatsby `RootWrapper`: sync html class, data attribute, and surface bg from the live theme. */
13
+ function DocumentColorModeSurface() {
14
+ useDocumentColorModeSurface()
15
+ return null
16
+ }
17
+
18
+ /**
19
+ * Default Next.js App Router shell: Theme UI provider, three.js Color Bends background (same as
20
+ * Gatsby home), document surface sync, and soft-navigation color-mode reconcile. Wrap with
21
+ * {@link ChronogroveNextEmotionRegistry} in `layout.jsx` outside this component.
22
+ */
23
+ export function ChronogroveNextAppShell({ children, theme = chronogroveTheme }) {
24
+ return (
25
+ <ChronogroveThemeProvider theme={theme}>
26
+ <ChronogroveAnimatedPageBackground />
27
+ <DocumentColorModeSurface />
28
+ <Box sx={{ position: 'relative', zIndex: 1, bg: 'transparent', color: 'text' }}>
29
+ <ChronogroveNextThemeUiColorModeRouteSync />
30
+ {children}
31
+ </Box>
32
+ </ChronogroveThemeProvider>
33
+ )
34
+ }
@@ -0,0 +1,68 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { CacheProvider } from '@emotion/react'
5
+ import createCache from '@emotion/cache'
6
+ import { useServerInsertedHTML } from 'next/navigation'
7
+
8
+ /**
9
+ * Emotion cache for Next.js App Router: streaming SSR via `useServerInsertedHTML`, key `css`
10
+ * to match Theme UI / Chronogrove.
11
+ *
12
+ * Intercepts `cache.insert` and flushes only *new* rule names on each `useServerInsertedHTML`
13
+ * invocation. Do **not** use `Object.keys(cache.inserted)` (or similar) in the hook body — that
14
+ * re-emits every rule on every chunk during streaming SSR. Here, `flush()` drains a per-request
15
+ * queue so each rule is serialized once; `cache.inserted` stays as Emotion’s dedupe store.
16
+ *
17
+ * Next.js **pushes** a new callback from every render (`serverInsertedHTMLCallbacks.push`); all
18
+ * closures call the same `flush`, so only the first callback in a flush pass drains pending names.
19
+ *
20
+ * @see https://github.com/emotion-js/emotion/issues/2928
21
+ *
22
+ * @see https://nextjs.org/docs/app/building-your-application/styling/css-in-js
23
+ */
24
+ export function ChronogroveNextEmotionRegistry({ children }) {
25
+ const [{ cache, flush }] = React.useState(() => {
26
+ const c = createCache({ key: 'css' })
27
+ c.compat = true
28
+ const prevInsert = c.insert
29
+ let pendingNames = []
30
+ c.insert = (...args) => {
31
+ const serialized = args[1]
32
+ if (serialized != null && c.inserted[serialized.name] === undefined) {
33
+ pendingNames.push(serialized.name)
34
+ }
35
+ return prevInsert(...args)
36
+ }
37
+ const flush = () => {
38
+ const names = pendingNames
39
+ pendingNames = []
40
+ return names
41
+ }
42
+ return { cache: c, flush }
43
+ })
44
+
45
+ useServerInsertedHTML(() => {
46
+ const names = flush()
47
+ if (names.length === 0) {
48
+ return null
49
+ }
50
+ let styles = ''
51
+ for (const name of names) {
52
+ const inserted = cache.inserted[name]
53
+ // `inserted` can be `true` for Global/keyframes when rules are already in a sheet (Next.js + Emotion pattern).
54
+ if (typeof inserted === 'string') {
55
+ styles += inserted
56
+ }
57
+ }
58
+ return (
59
+ <style
60
+ key={cache.key}
61
+ data-emotion={`${cache.key} ${names.join(' ')}`}
62
+ dangerouslySetInnerHTML={{ __html: styles }}
63
+ />
64
+ )
65
+ })
66
+
67
+ return <CacheProvider value={cache}>{children}</CacheProvider>
68
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ jest.mock('next/navigation', () => ({
6
+ useServerInsertedHTML: jest.fn()
7
+ }))
8
+
9
+ import React from 'react'
10
+ import { render } from '@testing-library/react'
11
+ import { Global, css } from '@emotion/react'
12
+ import { useServerInsertedHTML } from 'next/navigation'
13
+
14
+ import { ChronogroveNextEmotionRegistry } from './emotion-registry.js'
15
+
16
+ /** `className={css({...})}` does not always hit `cache.insert` in Jest/jsdom; `Global` does. */
17
+ function EmotionChild({ color }) {
18
+ return (
19
+ <Global
20
+ styles={css`
21
+ body {
22
+ color: ${color};
23
+ }
24
+ `}
25
+ />
26
+ )
27
+ }
28
+
29
+ describe('ChronogroveNextEmotionRegistry', () => {
30
+ let flushCallbacks
31
+
32
+ beforeEach(() => {
33
+ flushCallbacks = []
34
+ useServerInsertedHTML.mockImplementation(cb => {
35
+ flushCallbacks.push(cb)
36
+ })
37
+ })
38
+
39
+ it('flushes only new insertions per useServerInsertedHTML call (avoids duplicate style tags when streaming)', () => {
40
+ const { rerender } = render(
41
+ <ChronogroveNextEmotionRegistry>
42
+ <EmotionChild color='tomato' />
43
+ </ChronogroveNextEmotionRegistry>
44
+ )
45
+
46
+ const first = flushCallbacks[flushCallbacks.length - 1]()
47
+ expect(first).not.toBeNull()
48
+ // `cache.inserted[id]` can be `true` for Global in jsdom; must not stringify to invalid CSS.
49
+ expect(first.props.dangerouslySetInnerHTML.__html).not.toMatch(/^true+$/)
50
+
51
+ expect(flushCallbacks[flushCallbacks.length - 1]()).toBeNull()
52
+
53
+ rerender(
54
+ <ChronogroveNextEmotionRegistry>
55
+ <EmotionChild color='steelblue' />
56
+ </ChronogroveNextEmotionRegistry>
57
+ )
58
+
59
+ const afterNewRules = flushCallbacks[flushCallbacks.length - 1]()
60
+ expect(afterNewRules).not.toBeNull()
61
+ expect(flushCallbacks[flushCallbacks.length - 1]()).toBeNull()
62
+ })
63
+
64
+ /**
65
+ * Next.js registers `useServerInsertedHTML` once per render (`push` onto an array). All handlers
66
+ * share one `flush`; only the first invocation per drain should emit — not N copies of the full
67
+ * `cache.inserted` map.
68
+ */
69
+ it('drains pending styles once when multiple handlers run (matches Next.js callback stacking)', () => {
70
+ const { rerender } = render(
71
+ <ChronogroveNextEmotionRegistry>
72
+ <EmotionChild color='tomato' />
73
+ </ChronogroveNextEmotionRegistry>
74
+ )
75
+
76
+ expect(flushCallbacks).toHaveLength(1)
77
+
78
+ rerender(
79
+ <ChronogroveNextEmotionRegistry>
80
+ <EmotionChild color='tomato' />
81
+ </ChronogroveNextEmotionRegistry>
82
+ )
83
+ rerender(
84
+ <ChronogroveNextEmotionRegistry>
85
+ <EmotionChild color='tomato' />
86
+ </ChronogroveNextEmotionRegistry>
87
+ )
88
+
89
+ expect(flushCallbacks).toHaveLength(3)
90
+
91
+ const html0 = flushCallbacks[0]()
92
+ const html1 = flushCallbacks[1]()
93
+ const html2 = flushCallbacks[2]()
94
+
95
+ expect(html0).not.toBeNull()
96
+ expect(html1).toBeNull()
97
+ expect(html2).toBeNull()
98
+ })
99
+ })
@@ -0,0 +1,4 @@
1
+ export { ChronogroveNextAppShell } from './app-shell.js'
2
+ export { ChronogroveNextEmotionRegistry } from './emotion-registry.js'
3
+ export { ChronogroveNextRootLayoutHead } from './root-layout-head.js'
4
+ export { ChronogroveNextThemeUiColorModeRouteSync } from './theme-ui-color-mode-route-sync.js'