diff --git a/.changeset/missing-app-key-message.md b/.changeset/missing-app-key-message.md new file mode 100644 index 00000000..76df1fa9 --- /dev/null +++ b/.changeset/missing-app-key-message.md @@ -0,0 +1,6 @@ +--- +'@youversion/platform-react-hooks': minor +'@youversion/platform-react-ui': minor +--- + +Surface a clear error when `YouVersionProvider` is given a missing or empty `appKey` instead of rendering a blank page. The UI provider now renders a styled "Missing app key" message, and the hooks provider throws a descriptive error for hooks-only consumers. diff --git a/examples/vite-react/src/ThemedApp.tsx b/examples/vite-react/src/ThemedApp.tsx index 7aeda2dd..c91fb4d4 100644 --- a/examples/vite-react/src/ThemedApp.tsx +++ b/examples/vite-react/src/ThemedApp.tsx @@ -4,7 +4,9 @@ import App from './App'; export default function ThemedApp() { const { theme } = useTheme(); - const appKey = import.meta.env.VITE_YVP_APP_KEY ?? ''; + // Intentionally not defaulted: when VITE_YVP_APP_KEY is unset the SDK shows a + // "Missing app key" message instead of a blank page. + const appKey = import.meta.env.VITE_YVP_APP_KEY; const apiHost = import.meta.env.VITE_YVP_API_HOST ?? 'api.youversion.com'; const authRedirectUrl = import.meta.env.VITE_YVP_AUTH_REDIRECT_URL ?? window.location.origin; @@ -12,7 +14,7 @@ export default function ThemedApp() { diff --git a/packages/hooks/src/context/YouVersionProvider.test.tsx b/packages/hooks/src/context/YouVersionProvider.test.tsx index f8676fdf..b5378f7b 100644 --- a/packages/hooks/src/context/YouVersionProvider.test.tsx +++ b/packages/hooks/src/context/YouVersionProvider.test.tsx @@ -26,4 +26,19 @@ describe('YouVersionProvider', () => { expect(id).toBeTruthy(); expect(id).not.toBe('none'); }); + + it.each([ + ['undefined', undefined], + ['empty string', ''], + ['whitespace only', ' '], + ])('throws when appKey is %s', (_label, appKey) => { + expect(() => + render( + // @ts-expect-error -- exercising the runtime guard with an invalid appKey + + + , + ), + ).toThrow(/non-empty "appKey" is required/); + }); }); diff --git a/packages/hooks/src/context/YouVersionProvider.tsx b/packages/hooks/src/context/YouVersionProvider.tsx index 8d7fbbaa..cf54a94d 100644 --- a/packages/hooks/src/context/YouVersionProvider.tsx +++ b/packages/hooks/src/context/YouVersionProvider.tsx @@ -70,6 +70,28 @@ function useResolvedTheme(theme: 'light' | 'dark' | 'system'): 'light' | 'dark' export function YouVersionProvider( props: PropsWithChildren, +): React.ReactElement { + // Fail loudly on a missing/empty app key. Without this the SDK renders an + // empty shell and only surfaces errors in the console — see YPE-1565. The UI + // package's provider catches this earlier and renders a styled message + // instead; this throw is the baseline guarantee for hooks-only consumers. + // + // The guard lives in this thin wrapper so the hook-bearing implementation is + // never entered with an invalid key. Keeping the throw out of the component + // that calls hooks avoids any hook-order inconsistency if a mounted provider + // ever transitions between a valid and an empty appKey. + if (!props.appKey?.trim()) { + throw new Error( + 'YouVersionProvider: a non-empty "appKey" is required. If you load it from an ' + + 'environment variable, make sure it is set and restart your dev server.', + ); + } + + return ; +} + +function YouVersionProviderInner( + props: PropsWithChildren, ): React.ReactElement { const { appKey, @@ -79,6 +101,7 @@ export function YouVersionProvider( additionalHeaders, children, } = props; + const resolvedTheme = useResolvedTheme(theme); // Stable identity so memoized consumers (hooks that build ApiClient) don't diff --git a/packages/ui/src/components/YouVersionProvider.test.tsx b/packages/ui/src/components/YouVersionProvider.test.tsx index 69ecc7ff..64850208 100644 --- a/packages/ui/src/components/YouVersionProvider.test.tsx +++ b/packages/ui/src/components/YouVersionProvider.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { describe, it, expect, vi } from 'vitest'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { YouVersionProvider } from '@/components/YouVersionProvider'; @@ -45,4 +45,32 @@ describe('UI YouVersionProvider', () => { expect(lastCall?.appKey).toBe('test-key'); expect(lastCall?.additionalHeaders).toBeUndefined(); }); + + it.each([ + ['undefined', undefined], + ['empty string', ''], + ['whitespace only', ' '], + ])( + 'renders the missing-app-key message and skips the base provider when appKey is %s', + (_label, appKey) => { + baseProviderMock.mockClear(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + render( + // @ts-expect-error -- exercising the runtime guard with an invalid appKey + +
hello
+
, + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.queryByTestId('child')).not.toBeInTheDocument(); + expect(baseProviderMock).not.toHaveBeenCalled(); + // The actionable guidance for developers lives in console.error, not the panel. + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('appKey')); + + errorSpy.mockRestore(); + }, + ); }); diff --git a/packages/ui/src/components/YouVersionProvider.tsx b/packages/ui/src/components/YouVersionProvider.tsx index 0c911a78..b3fc6cb7 100644 --- a/packages/ui/src/components/YouVersionProvider.tsx +++ b/packages/ui/src/components/YouVersionProvider.tsx @@ -2,6 +2,13 @@ import React, { type ComponentProps, useEffect } from 'react'; import { YouVersionProvider as BaseYouVersionProvider } from '@youversion/platform-react-hooks'; import { syncBrowserLanguageFromNavigator } from '@/i18n'; import { YvStyles } from '@/lib/yv-styles'; +import { MissingAppKey } from '@/components/missing-app-key'; + +function resolveTheme(theme: 'light' | 'dark' | 'system' = 'light'): 'light' | 'dark' { + if (theme !== 'system') return theme; + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} export function YouVersionProvider( props: ComponentProps, @@ -10,6 +17,34 @@ export function YouVersionProvider( syncBrowserLanguageFromNavigator(); }, []); + // Guard against a missing/empty app key here (rather than letting the base + // provider throw) so consumers of the UI package see a styled message instead + // of a blank page. The visible panel is intentionally generic; the actionable + // fix (set the env var, restart the dev server) goes to console.error for the + // developer. Hooks-only consumers still get a thrown error from the base + // provider. + const missingAppKey = !props.appKey?.trim(); + + // Log from an effect (not the render body) so the guidance is emitted once per + // state change instead of on every re-render and twice under Strict Mode. + useEffect(() => { + if (missingAppKey) { + console.error( + 'YouVersionProvider: a non-empty "appKey" is required. If you load it from an ' + + 'environment variable, make sure it is set and restart your dev server.', + ); + } + }, [missingAppKey]); + + if (missingAppKey) { + return ( + <> + + + + ); + } + return ( diff --git a/packages/ui/src/components/missing-app-key.stories.tsx b/packages/ui/src/components/missing-app-key.stories.tsx new file mode 100644 index 00000000..b14d7092 --- /dev/null +++ b/packages/ui/src/components/missing-app-key.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { within, expect, waitFor } from 'storybook/test'; +import { MissingAppKey } from './missing-app-key'; + +const meta = { + title: 'Components/MissingAppKey', + component: MissingAppKey, + parameters: { + layout: 'fullscreen', + }, + render: (args) => ( +
+ +
+ ), + tags: ['autodocs'], + argTypes: { + theme: { + control: 'inline-radio', + options: ['light', 'dark'], + description: 'Color theme for the message', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Light: Story = { + args: { + theme: 'light', + }, +}; + +export const Dark: Story = { + args: { + theme: 'dark', + }, + parameters: { + backgrounds: { default: 'dark' }, + }, +}; + +export const RendersMessage: Story = { + args: { + theme: 'light', + }, + tags: ['integration'], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(async () => { + await expect(canvas.getByRole('alert')).toBeInTheDocument(); + }); + + await expect(canvas.getByText('Error')).toBeInTheDocument(); + await expect(canvas.getByText(/app key/)).toBeInTheDocument(); + }, +}; diff --git a/packages/ui/src/components/missing-app-key.tsx b/packages/ui/src/components/missing-app-key.tsx new file mode 100644 index 00000000..dde3bdea --- /dev/null +++ b/packages/ui/src/components/missing-app-key.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; +import { ExclamationCircle } from '@/components/icons/exclamation-circle'; + +/** + * Styled panel shown by {@link YouVersionProvider} when no (or an empty) + * `appKey` is supplied. Replaces the provider's children so a misconfigured app + * surfaces an actionable message instead of a blank page. + */ +export function MissingAppKey({ + theme = 'light', +}: { + theme?: 'light' | 'dark'; +}): React.ReactElement { + const { t } = useTranslation(undefined, { i18n }); + + return ( +
+
+ ); +}