Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/missing-app-key-message.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions examples/vite-react/src/ThemedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ 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;

return (
<YouVersionProvider
theme={theme}
apiHost={apiHost}
appKey={appKey}
appKey={appKey ?? ''}
Comment thread
cameronapak marked this conversation as resolved.
includeAuth
authRedirectUrl={authRedirectUrl}
>
Expand Down
15 changes: 15 additions & 0 deletions packages/hooks/src/context/YouVersionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<YouVersionProvider appKey={appKey}>
<ContextReader />
</YouVersionProvider>,
),
).toThrow(/non-empty "appKey" is required/);
});
});
23 changes: 23 additions & 0 deletions packages/hooks/src/context/YouVersionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ function useResolvedTheme(theme: 'light' | 'dark' | 'system'): 'light' | 'dark'

export function YouVersionProvider(
props: PropsWithChildren<YouVersionProviderPropsWithAuth | YouVersionProviderPropsWithoutAuth>,
): 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 <YouVersionProviderInner {...props} />;
}

function YouVersionProviderInner(
props: PropsWithChildren<YouVersionProviderPropsWithAuth | YouVersionProviderPropsWithoutAuth>,
): React.ReactElement {
const {
appKey,
Expand All @@ -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
Expand Down
30 changes: 29 additions & 1 deletion packages/ui/src/components/YouVersionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
<YouVersionProvider appKey={appKey}>
<div data-testid="child">hello</div>
</YouVersionProvider>,
);

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();
},
);
});
35 changes: 35 additions & 0 deletions packages/ui/src/components/YouVersionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof BaseYouVersionProvider>,
Expand All @@ -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 (
<>
<YvStyles />
<MissingAppKey theme={resolveTheme(props.theme)} />
</>
);
}

return (
<BaseYouVersionProvider {...props}>
<YvStyles />
Expand Down
60 changes: 60 additions & 0 deletions packages/ui/src/components/missing-app-key.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="yv:w-full yv:max-w-md">
<MissingAppKey {...args} />
</div>
),
tags: ['autodocs'],
argTypes: {
theme: {
control: 'inline-radio',
options: ['light', 'dark'],
description: 'Color theme for the message',
},
},
} satisfies Meta<typeof MissingAppKey>;

export default meta;

type Story = StoryObj<typeof meta>;

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();
},
};
36 changes: 36 additions & 0 deletions packages/ui/src/components/missing-app-key.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-yv-sdk
data-yv-theme={theme}
role="alert"
className="yv:flex yv:items-start yv:gap-2.5 yv:p-4 yv:bg-background yv:text-foreground"
>
<ExclamationCircle className="yv:size-5 yv:shrink-0 yv:text-foreground" aria-hidden="true" />
<div className="yv:flex yv:flex-col yv:gap-1">
<p className="yv:m-0 yv:text-sm yv:font-semibold yv:leading-tight">{t('errorHeading')}</p>
<p className="yv:m-0 yv:text-[13px] yv:font-medium yv:leading-snug yv:text-muted-foreground">
{t('invalidAppKeyError')}
</p>
</div>
</div>
);
}
Loading