diff --git a/app/_layout.tsx b/app/_layout.tsx index 108eb9c..88e5bf4 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,6 +12,10 @@ import { useDeepLink } from '../src/hooks/useDeepLink'; import { sessionRestorationService } from '../src/services/sessionRestoration'; import { useAppStore } from '../src/store'; import { getPathFromDeepLink } from '../src/utils/linkParser'; +import { prefetchExternalResources } from '../src/utils/resourceHints'; + +// Kick off resource hints as early as possible — fire-and-forget, never blocks startup. +prefetchExternalResources(); // Component to handle auto screen tracking and session state persistence const ScreenTracker = () => { diff --git a/src/__tests__/utils/resourceHints.test.ts b/src/__tests__/utils/resourceHints.test.ts new file mode 100644 index 0000000..f8e6e2e --- /dev/null +++ b/src/__tests__/utils/resourceHints.test.ts @@ -0,0 +1,243 @@ +/** + * @jest-environment jsdom + * + * Tests for the resource hints utility (#409). + * + * Covers: + * - DEFAULT_RESOURCE_HINTS shape and required entries + * - applyResourceHints on web (link-tag injection, idempotency, invalid URLs) + * - applyResourceHints on native (HEAD warm-up, parallel execution, failures) + * - prefetchExternalResources (fire-and-forget, never throws) + */ + +import { Platform } from 'react-native'; + +import { + applyResourceHints, + DEFAULT_RESOURCE_HINTS, + prefetchExternalResources, + ResourceHint, +} from '@/utils/resourceHints'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function clearLinkTags() { + document + .querySelectorAll('link[rel="preconnect"], link[rel="dns-prefetch"]') + .forEach(el => el.remove()); +} + +function setPlatform(os: 'ios' | 'android' | 'web') { + Object.defineProperty(Platform, 'OS', { value: os, configurable: true }); +} + +// ─── DEFAULT_RESOURCE_HINTS ─────────────────────────────────────────────────── + +describe('DEFAULT_RESOURCE_HINTS', () => { + it('is a non-empty array', () => { + expect(Array.isArray(DEFAULT_RESOURCE_HINTS)).toBe(true); + expect(DEFAULT_RESOURCE_HINTS.length).toBeGreaterThan(0); + }); + + it('every entry has a valid URL', () => { + for (const hint of DEFAULT_RESOURCE_HINTS) { + expect(() => new URL(hint.url)).not.toThrow(); + } + }); + + it('every entry with an explicit type uses a recognised value', () => { + for (const hint of DEFAULT_RESOURCE_HINTS) { + if (hint.type !== undefined) { + expect(['preconnect', 'dns-prefetch']).toContain(hint.type); + } + } + }); + + it('includes a CDN preconnect entry', () => { + const found = DEFAULT_RESOURCE_HINTS.find( + h => h.url.includes('cdn') && h.type === 'preconnect' + ); + expect(found).toBeDefined(); + }); + + it('includes a dns-prefetch entry for analytics', () => { + const found = DEFAULT_RESOURCE_HINTS.find( + h => h.url.includes('analytics') && h.type === 'dns-prefetch' + ); + expect(found).toBeDefined(); + }); +}); + +// ─── applyResourceHints — web ───────────────────────────────────────────────── + +describe('applyResourceHints (web)', () => { + beforeEach(() => { + setPlatform('web'); + clearLinkTags(); + }); + + afterEach(() => { + clearLinkTags(); + }); + + it('injects a tag', async () => { + const hints: ResourceHint[] = [{ url: 'https://cdn.example.com', type: 'preconnect' }]; + const result = await applyResourceHints(hints); + + expect(result.succeeded).toContain('https://cdn.example.com'); + expect(result.failed).toHaveLength(0); + expect( + document.querySelector('link[rel="preconnect"][href="https://cdn.example.com"]') + ).not.toBeNull(); + }); + + it('injects a tag', async () => { + const hints: ResourceHint[] = [{ url: 'https://analytics.example.com', type: 'dns-prefetch' }]; + await applyResourceHints(hints); + + expect( + document.querySelector('link[rel="dns-prefetch"][href="https://analytics.example.com"]') + ).not.toBeNull(); + }); + + it('defaults to preconnect when type is omitted', async () => { + await applyResourceHints([{ url: 'https://default.example.com' }]); + + expect( + document.querySelector('link[rel="preconnect"][href="https://default.example.com"]') + ).not.toBeNull(); + }); + + it('is idempotent — does not inject duplicate tags', async () => { + const hints: ResourceHint[] = [{ url: 'https://cdn.example.com', type: 'preconnect' }]; + await applyResourceHints(hints); + await applyResourceHints(hints); + + const tags = document.querySelectorAll( + 'link[rel="preconnect"][href="https://cdn.example.com"]' + ); + expect(tags.length).toBe(1); + }); + + it('reports invalid URLs in the failed array', async () => { + const result = await applyResourceHints([{ url: 'not-a-valid-url', type: 'preconnect' }]); + + expect(result.failed).toContain('not-a-valid-url'); + expect(result.succeeded).toHaveLength(0); + }); + + it('handles a mix of valid and invalid URLs correctly', async () => { + const hints: ResourceHint[] = [ + { url: 'https://a.example.com', type: 'preconnect' }, + { url: 'bad-url' }, + { url: 'https://b.example.com', type: 'dns-prefetch' }, + ]; + const result = await applyResourceHints(hints); + + expect(result.succeeded).toHaveLength(2); + expect(result.failed).toHaveLength(1); + }); +}); + +// ─── applyResourceHints — native ───────────────────────────────────────────── + +describe('applyResourceHints (native)', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + setPlatform('ios'); + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('fires HEAD requests and reports successes', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response); + + const hints: ResourceHint[] = [ + { url: 'https://cdn.example.com', type: 'preconnect' }, + { url: 'https://analytics.example.com', type: 'dns-prefetch' }, + ]; + const result = await applyResourceHints(hints); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(result.succeeded).toHaveLength(2); + expect(result.failed).toHaveLength(0); + }); + + it('reports a failed entry when fetch rejects', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const result = await applyResourceHints([ + { url: 'https://cdn.example.com', type: 'preconnect' }, + ]); + + expect(result.failed).toContain('https://cdn.example.com'); + expect(result.succeeded).toHaveLength(0); + }); + + it('does not call fetch for an invalid URL', async () => { + global.fetch = jest.fn(); + + const result = await applyResourceHints([{ url: 'not-a-url' }]); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(result.failed).toContain('not-a-url'); + }); + + it('runs all hints in parallel', async () => { + // Each fetch resolves after 20 ms; sequential would take ~60 ms + global.fetch = jest + .fn() + .mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ ok: true } as Response), 20)) + ); + + const hints: ResourceHint[] = [ + { url: 'https://a.example.com' }, + { url: 'https://b.example.com' }, + { url: 'https://c.example.com' }, + ]; + + const start = Date.now(); + const result = await applyResourceHints(hints); + const elapsed = Date.now() - start; + + // Parallel: all 3 complete in ~20 ms, not ~60 ms + expect(elapsed).toBeLessThan(200); + expect(result.succeeded).toHaveLength(3); + }); + + it('also works on android', async () => { + setPlatform('android'); + global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response); + + const result = await applyResourceHints([{ url: 'https://cdn.example.com' }]); + + expect(result.succeeded).toContain('https://cdn.example.com'); + }); +}); + +// ─── prefetchExternalResources ──────────────────────────────────────────────── + +describe('prefetchExternalResources', () => { + beforeEach(() => { + // Stub applyResourceHints so the fire-and-forget promise resolves cleanly + global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not throw', () => { + expect(() => prefetchExternalResources()).not.toThrow(); + }); + + it('returns undefined (fire-and-forget)', () => { + expect(prefetchExternalResources()).toBeUndefined(); + }); +}); diff --git a/src/utils/resourceHints.ts b/src/utils/resourceHints.ts new file mode 100644 index 0000000..3d2f5ec --- /dev/null +++ b/src/utils/resourceHints.ts @@ -0,0 +1,185 @@ +/** + * Resource hints utility (#409) + * + * Implements preconnect and DNS-prefetch for external resources so that + * TCP/TLS handshakes and DNS lookups are resolved before the first real + * request is made, reducing perceived latency. + * + * Platform behaviour: + * - Web: injects / into + * the document (standard browser resource hints). + * - Native: issues a lightweight HEAD request to warm up the connection. + * Falls back silently on any network error so startup is never + * blocked. + */ + +import { Platform } from 'react-native'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type ResourceHintType = 'preconnect' | 'dns-prefetch'; + +export interface ResourceHint { + /** Fully-qualified origin, e.g. "https://cdn.teachlink.com" */ + url: string; + /** Hint type. Defaults to "preconnect". */ + type?: ResourceHintType; + /** + * Whether to include credentials (CORS). + * Only relevant for preconnect on web. Defaults to false. + */ + crossOrigin?: boolean; +} + +export interface ResourceHintsResult { + /** Origins that were successfully hinted / warmed up. */ + succeeded: string[]; + /** Origins that failed (network error, invalid URL, etc.). */ + failed: string[]; +} + +// ─── Default external resources ─────────────────────────────────────────────── + +/** + * External origins used by TeachLink. + * Add / remove entries here as the infrastructure changes. + */ +export const DEFAULT_RESOURCE_HINTS: ResourceHint[] = [ + // CDN for course assets (images, videos) + { url: 'https://cdn.teachlink.com', type: 'preconnect' }, + // Analytics endpoint + { url: 'https://analytics.teachlink.com', type: 'dns-prefetch' }, + // Ad / monetisation network + { url: 'https://ads.teachlink.com', type: 'dns-prefetch' }, + // Sentry error reporting + { url: 'https://o0.ingest.sentry.io', type: 'preconnect' }, +]; + +// ─── Web implementation ─────────────────────────────────────────────────────── + +/** + * Inject a resource-hint tag into the document . + * No-ops if the tag already exists (idempotent). + */ +function injectWebHint(hint: ResourceHint): void { + if (typeof document === 'undefined') return; + + const { url, type = 'preconnect', crossOrigin = false } = hint; + const rel = type; + + // Avoid duplicate tags + const existing = document.querySelector(`link[rel="${rel}"][href="${url}"]`); + if (existing) return; + + const link = document.createElement('link'); + link.rel = rel; + link.href = url; + if (crossOrigin) link.crossOrigin = 'anonymous'; + + document.head.appendChild(link); +} + +// ─── Native implementation ──────────────────────────────────────────────────── + +/** + * Warm up a connection on native by issuing a HEAD request. + * The response body is discarded; we only care about establishing the + * TCP/TLS session in the OS network stack. + * + * Returns true on success, false on any error. + */ +async function warmUpNative(url: string): Promise { + try { + // Validate URL before attempting the request + new URL(url); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 s timeout + + await fetch(url, { + method: 'HEAD', + signal: controller.signal, + // Prevent caching the warm-up request itself + headers: { 'Cache-Control': 'no-store' }, + }); + + clearTimeout(timeoutId); + return true; + } catch { + // Network errors, CORS, timeouts — all silently ignored + return false; + } +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Apply resource hints for a list of external origins. + * + * - On **web**: injects `` / `` + * tags into ``. + * - On **native**: fires background HEAD requests to warm up connections. + * + * This function never throws; failures are collected in the returned result. + * + * @param hints - Array of resource hints to apply. + * Defaults to `DEFAULT_RESOURCE_HINTS`. + */ +export async function applyResourceHints( + hints: ResourceHint[] = DEFAULT_RESOURCE_HINTS +): Promise { + const result: ResourceHintsResult = { succeeded: [], failed: [] }; + + if (Platform.OS === 'web') { + // Synchronous on web — just inject the link tags + for (const hint of hints) { + try { + new URL(hint.url); // validate + injectWebHint(hint); + result.succeeded.push(hint.url); + } catch { + result.failed.push(hint.url); + } + } + return result; + } + + // Native: fire all warm-up requests in parallel + const outcomes = await Promise.allSettled( + hints.map(async hint => { + const ok = await warmUpNative(hint.url); + return { url: hint.url, ok }; + }) + ); + + for (const outcome of outcomes) { + if (outcome.status === 'fulfilled') { + const { url, ok } = outcome.value; + if (ok) { + result.succeeded.push(url); + } else { + result.failed.push(url); + } + } else { + // Promise itself rejected (shouldn't happen, but be safe) + result.failed.push('unknown'); + } + } + + return result; +} + +/** + * Convenience wrapper: apply the default resource hints and log the outcome + * in development. Safe to call at app startup without awaiting. + */ +export function prefetchExternalResources(): void { + applyResourceHints(DEFAULT_RESOURCE_HINTS).then(result => { + if (__DEV__) { + console.log( + `[ResourceHints] preconnect/dns-prefetch: ` + + `${result.succeeded.length} succeeded, ${result.failed.length} failed` + ); + } + }); +}