lazyPage.test.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. /**
  2. * Pages load as separate files (#3175). A tab from before an update asks for
  3. * files the update deleted: it must reload once, never loop, and never while
  4. * the server is down (a kiosk would be left on the browser's error page).
  5. */
  6. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  7. import { render, screen } from '@testing-library/react';
  8. import { Component, Suspense, type ReactNode } from 'react';
  9. const originalLocation = window.location;
  10. let reload: ReturnType<typeof vi.fn>;
  11. let health: ReturnType<typeof vi.fn>;
  12. async function freshModule() {
  13. vi.resetModules();
  14. return import('../../utils/lazyPage');
  15. }
  16. class Catch extends Component<{ children: ReactNode }, { error: string | null }> {
  17. state = { error: null as string | null };
  18. static getDerivedStateFromError(error: Error) {
  19. return { error: error.message };
  20. }
  21. render() {
  22. return this.state.error ? <p>crashed: {this.state.error}</p> : this.props.children;
  23. }
  24. }
  25. beforeEach(() => {
  26. reload = vi.fn();
  27. Object.defineProperty(window, 'location', { configurable: true, value: { ...originalLocation, reload } });
  28. window.sessionStorage.clear();
  29. health = vi.fn().mockResolvedValue(new Response('{"status":"healthy"}', { status: 200 }));
  30. vi.stubGlobal('fetch', health);
  31. });
  32. afterEach(() => {
  33. Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
  34. vi.unstubAllGlobals();
  35. vi.useRealTimers();
  36. vi.restoreAllMocks();
  37. });
  38. describe('reloadForMissingChunk', () => {
  39. it('reloads once and records when', async () => {
  40. const { reloadForMissingChunk } = await freshModule();
  41. expect(reloadForMissingChunk()).toBe(true);
  42. expect(reload).toHaveBeenCalledTimes(1);
  43. expect(Number(window.sessionStorage.getItem('bambuddy_chunk_reload_at'))).toBeGreaterThan(0);
  44. // A second failure in the same page while the reload is under way.
  45. expect(reloadForMissingChunk()).toBe(true);
  46. expect(reload).toHaveBeenCalledTimes(1);
  47. });
  48. it('does not reload again right after its own reload', async () => {
  49. window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now() - 2000));
  50. const { reloadForMissingChunk } = await freshModule();
  51. expect(reloadForMissingChunk()).toBe(false);
  52. expect(reload).not.toHaveBeenCalled();
  53. });
  54. it('reloads again for a later update', async () => {
  55. window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now() - 60_000));
  56. const { reloadForMissingChunk } = await freshModule();
  57. expect(reloadForMissingChunk()).toBe(true);
  58. expect(reload).toHaveBeenCalledTimes(1);
  59. });
  60. it('never reloads without sessionStorage, since nothing could stop a loop', async () => {
  61. vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
  62. throw new Error('blocked');
  63. });
  64. const { reloadForMissingChunk } = await freshModule();
  65. expect(reloadForMissingChunk()).toBe(false);
  66. expect(reload).not.toHaveBeenCalled();
  67. });
  68. });
  69. describe('lazyPage', () => {
  70. it('renders the named export', async () => {
  71. const { lazyPage } = await freshModule();
  72. const Page = lazyPage(async () => ({ HelloPage: () => <p>hello page</p> }), 'HelloPage');
  73. render(<Suspense fallback={<p>loading</p>}><Page /></Suspense>);
  74. expect(await screen.findByText('hello page')).toBeInTheDocument();
  75. expect(reload).not.toHaveBeenCalled();
  76. });
  77. it('reloads instead of crashing when the file is gone', async () => {
  78. const { lazyPage } = await freshModule();
  79. const Page = lazyPage(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module')), 'X');
  80. render(<Catch><Suspense fallback={<p>loading</p>}><Page /></Suspense></Catch>);
  81. await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
  82. expect(screen.getByText('loading')).toBeInTheDocument();
  83. expect(screen.queryByText(/crashed/)).not.toBeInTheDocument();
  84. });
  85. it('keeps the spinner while the server is down, then reloads', async () => {
  86. let up = false;
  87. health.mockImplementation(() =>
  88. up ? Promise.resolve(new Response('', { status: 200 })) : Promise.reject(new TypeError('Failed to fetch')),
  89. );
  90. const { lazyPage } = await freshModule();
  91. const Page = lazyPage(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module')), 'X');
  92. render(<Catch><Suspense fallback={<p>loading</p>}><Page /></Suspense></Catch>);
  93. await vi.waitFor(() => expect(health).toHaveBeenCalled());
  94. expect(reload).not.toHaveBeenCalled();
  95. expect(screen.getByText('loading')).toBeInTheDocument();
  96. up = true;
  97. await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1), { timeout: 5000 });
  98. expect(screen.queryByText(/crashed/)).not.toBeInTheDocument();
  99. });
  100. it('shows the error when the reload did not help', async () => {
  101. window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now()));
  102. const { lazyPage } = await freshModule();
  103. vi.spyOn(console, 'error').mockImplementation(() => {});
  104. const Page = lazyPage(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module')), 'X');
  105. render(<Catch><Suspense fallback={<p>loading</p>}><Page /></Suspense></Catch>);
  106. expect(await screen.findByText(/crashed: Failed to fetch/)).toBeInTheDocument();
  107. expect(reload).not.toHaveBeenCalled();
  108. });
  109. });
  110. describe('recoverFromMissingChunk', () => {
  111. it('reloads straight away when the server answers', async () => {
  112. const { recoverFromMissingChunk } = await freshModule();
  113. await expect(recoverFromMissingChunk()).resolves.toBe(true);
  114. expect(health).toHaveBeenCalledWith('/health', { cache: 'no-store', redirect: 'manual' });
  115. expect(reload).toHaveBeenCalledTimes(1);
  116. });
  117. it('waits for the server to come back before reloading', async () => {
  118. vi.useFakeTimers();
  119. health
  120. .mockRejectedValueOnce(new TypeError('Failed to fetch'))
  121. .mockResolvedValueOnce(new Response('', { status: 502 }))
  122. .mockResolvedValueOnce(new Response('', { status: 522 }));
  123. const { recoverFromMissingChunk } = await freshModule();
  124. const recovered = recoverFromMissingChunk();
  125. await vi.advanceTimersByTimeAsync(3000);
  126. await vi.advanceTimersByTimeAsync(3000);
  127. expect(reload).not.toHaveBeenCalled();
  128. await vi.advanceTimersByTimeAsync(3000);
  129. await expect(recovered).resolves.toBe(true);
  130. expect(health).toHaveBeenCalledTimes(4);
  131. expect(reload).toHaveBeenCalledTimes(1);
  132. });
  133. it('counts a redirect to a login page as the server answering', async () => {
  134. health.mockResolvedValue({ status: 0, type: 'opaqueredirect' });
  135. const { recoverFromMissingChunk } = await freshModule();
  136. await expect(recoverFromMissingChunk()).resolves.toBe(true);
  137. expect(reload).toHaveBeenCalledTimes(1);
  138. });
  139. it('resolves false when its own reload did not help', async () => {
  140. window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now()));
  141. const { recoverFromMissingChunk } = await freshModule();
  142. await expect(recoverFromMissingChunk()).resolves.toBe(false);
  143. expect(reload).not.toHaveBeenCalled();
  144. });
  145. });