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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion public/_redirects
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# SPA deep-link fallback: unknown paths serve the app shell (client-side routing).
# Real files (static/*, widget/*, favicon, ...) are matched by the host first.
#
# Real files under static/*, widget/*, favicon, ... are matched by the host before this rule --
# but only while they EXIST. A build asset that does not exist falls through to this rule and is
# answered with index.html and status 200, and the /static/* rule in _headers then stamps that
# HTML with a year of immutable cache. Verify with a request for any absent asset:
# curl -sI https://app.dfx.swiss/static/js/999.deadbeef.chunk.js
# -> 200, content-type: text/html, cache-control: public, max-age=31536000, immutable
#
# This is what a client sees when it requests a chunk that a later deploy replaced: not a 404 it
# could recover from, but an app shell parsed as JavaScript. The app treats that as a chunk load
# failure and reloads once (src/util/client-error.ts), which recovers the client as long as the
# bad response was not cached under a chunk URL that a later build reuses unchanged.
#
# Closing that gap needs a host-side change, since _redirects cannot express a status of 404 and
# _headers cannot tell an existing asset apart from this fallback -- both match on path alone.
/* /index.html 200
6 changes: 6 additions & 0 deletions src/Main.lib.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { createMemoryRouter } from 'react-router-dom';
import App, { WidgetParams } from './App';
import { markEmbedded } from './util/client-error';

// Imported straight into someone else's React app, so this is the only place that can say so.
// Marking it turns off both recovery and the page-wide listeners: neither the consumer's page nor
// their window is ours to act on. Failures still reach the error screen and are reported there.
markEmbedded();

function MainLib(params: WidgetParams) {
return (
Expand Down
5 changes: 5 additions & 0 deletions src/Main.widget.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { createMemoryRouter } from 'react-router-dom';
import App, { WidgetParams } from './App';
import { markEmbedded } from './util/client-error';

// Runs on a third party's page: a chunk failure here is reported, never recovered by reloading
// their page.
markEmbedded();

function MainWidget(params: WidgetParams) {
return (
Expand Down
331 changes: 331 additions & 0 deletions src/__tests__/client-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
// Mock @dfx.swiss/react to avoid ES module issues
jest.mock('@dfx.swiss/react', () => ({}));
jest.mock('src/dto/safe.dto', () => ({}));

import { isChunkLoadError, reloadOnceForChunkError, reportClientError, toErrorFacts } from '../util/client-error';

jest.mock('src/config/api', () => ({ Api: { url: 'https://api.example.com', version: 'v1' } }));
jest.mock('src/version', () => ({ REACT_APP_BUILD_ID: '42-99' }));

const INGEST_URL = 'https://api.example.com/v1/log/clientError';

// react-router identifies these structurally, so the shape is what matters.
function routeErrorResponse(status: number, statusText: string): unknown {
return { status, statusText, data: '', internal: true };
}

function sentBody(): Record<string, string | undefined> {
return JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
}

describe('toErrorFacts', () => {
it('reads message, type and stack off an Error', () => {
const error = Object.assign(new Error('boom'), { name: 'ChunkLoadError', stack: 'at x' });

expect(toErrorFacts(error)).toEqual({ message: 'boom', type: 'ChunkLoadError', stack: 'at x' });
});

it('describes a route error response by its status', () => {
expect(toErrorFacts(routeErrorResponse(404, 'Not Found'))).toEqual({
message: '404 Not Found',
type: 'RouteErrorResponse',
});
});

it('falls back to the string form of anything else', () => {
expect(toErrorFacts('plain failure').message).toBe('plain failure');
expect(toErrorFacts(undefined).message).toBe('undefined');
});
});

describe('isChunkLoadError', () => {
it.each([
'Loading chunk 42 failed',
'Loading CSS chunk 7 failed',
'ChunkLoadError: something',
'Failed to fetch dynamically imported module: https://app.example.com/static/js/1.js',
])('recognises the bundler wording "%s"', (message) => {
expect(isChunkLoadError(new Error(message))).toBe(true);
});

// What a stale chunk answered with the app shell actually produces: the script loads, never
// registers the chunk, and the bundler's loader reports it as missing. Asserted on the message
// alone, without the name, so it pins the regex rather than passing on the name check.
it('recognises a chunk that loaded but never registered, by its message alone', () => {
const message = 'Loading chunk 738 failed.\n(missing: https://app.example.com/static/js/738.js)';

expect(isChunkLoadError(new Error(message))).toBe(true);
});

it('recognises the error by its name alone', () => {
expect(isChunkLoadError(Object.assign(new Error('nondescript'), { name: 'ChunkLoadError' }))).toBe(true);
});

it('does not classify an unrelated error as a chunk failure', () => {
expect(isChunkLoadError(new Error('Cannot read properties of undefined'))).toBe(false);
expect(isChunkLoadError(routeErrorResponse(404, 'Not Found'))).toBe(false);
});

// A reload discards whatever the customer had typed, so the cost of a false positive is high.
// This is the wording a JSON.parse gets when a gateway, WAF or login redirect answers an API
// call with HTML — an everyday failure that must not reload the page.
it('does not classify a JSON response that arrived as HTML as a chunk failure', () => {
let parseError: unknown;
try {
JSON.parse('<html><head>502 Bad Gateway</head></html>');
} catch (e) {
parseError = e;
}

expect(parseError).toBeInstanceOf(SyntaxError);
expect(isChunkLoadError(parseError)).toBe(false);
});

it.each(["Unexpected token '<'", "expected expression, got '<'", 'Unexpected token < in JSON at position 0'])(
'does not classify the bare syntax wording "%s" as a chunk failure',
(message) => {
expect(isChunkLoadError(new Error(message))).toBe(false);
},
);
});

describe('reportClientError', () => {
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({ ok: true }) as jest.Mock;
});

afterEach(() => jest.restoreAllMocks());

it('posts the error to the ingest endpoint', () => {
reportClientError(Object.assign(new Error('boom'), { name: 'TypeError', stack: 'at buy' }), '/buy');

expect(global.fetch).toHaveBeenCalledTimes(1);
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(INGEST_URL);
expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({ method: 'POST', keepalive: true });
expect(sentBody()).toEqual({
message: 'boom',
type: 'TypeError',
stack: 'at buy',
route: '/buy',
version: '42-99',
});
});

// The ingest endpoint rejects oversized fields, and a rejected report is a blind spot.
it('truncates fields to the limits the endpoint accepts', () => {
reportClientError(Object.assign(new Error('m'.repeat(900)), { stack: 's'.repeat(5000) }), '/r'.repeat(400));

expect(sentBody().message).toHaveLength(500);
expect(sentBody().stack).toHaveLength(4000);
expect(sentBody().route).toHaveLength(500);
});

it('swallows a failing report instead of raising a second error', () => {
global.fetch = jest.fn().mockRejectedValue(new Error('offline')) as jest.Mock;

expect(() => reportClientError(new Error('boom'), '/buy')).not.toThrow();
});

it('swallows a fetch that throws synchronously', () => {
global.fetch = jest.fn().mockImplementation(() => {
throw new Error('blocked');
}) as jest.Mock;

expect(() => reportClientError(new Error('boom'), '/buy')).not.toThrow();
});

// toErrorFacts falls back to String(error), which a hostile toString can turn into a throw.
it('swallows a thrown value whose string conversion throws', () => {
const hostile = {
toString: () => {
throw new Error('nope');
},
};

expect(() => reportClientError(hostile, '/buy')).not.toThrow();
});

it('identifies the app to the API', () => {
reportClientError(new Error('boom'), '/buy');

expect((global.fetch as jest.Mock).mock.calls[0][1].headers).toMatchObject({ 'x-client': 'dfx-services' });
});

// The endpoint requires a non-empty message; without a substitute the whole report is rejected
// and the failure stays invisible, which is the very gap this reporting closes.
it('substitutes a message when the error carries none', () => {
reportClientError(new Error(''), '/buy');

expect(sentBody().message).toBe('Unknown error');
});

it('keeps an empty stack out of the payload rather than sending an empty string', () => {
reportClientError(Object.assign(new Error('boom'), { stack: undefined }), '/buy');

expect(sentBody()).not.toHaveProperty('stack');
});
});

describe('reloadOnceForChunkError', () => {
let reload: jest.Mock;

beforeEach(() => {
localStorage.clear();
reload = jest.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
});

it('reloads on the first chunk failure', () => {
reloadOnceForChunkError();

expect(reload).toHaveBeenCalledTimes(1);
});

it('does not reload again within the guard window', () => {
reloadOnceForChunkError();
reloadOnceForChunkError();

expect(reload).toHaveBeenCalledTimes(1);
});

it('reloads again once the guard window has passed', () => {
localStorage.setItem('dfx.chunkReloadAt', String(Date.now() - 31000));

reloadOnceForChunkError();

expect(reload).toHaveBeenCalledTimes(1);
});

// Embedded contexts can block storage; without the guard a reload there would loop.
it('skips the reload when storage is blocked', () => {
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});

reloadOnceForChunkError();

expect(reload).not.toHaveBeenCalled();
});

// The widget and library builds run on a third party's page, where window is theirs. Reloading
// it over a deploy of ours would throw away state that has nothing to do with us. The module
// is loaded in isolation because marking it is a one-way switch.
it('does not reload the host page once marked as embedded', () => {
jest.isolateModules(() => {
const clientError = jest.requireActual('../util/client-error');

clientError.markEmbedded();
clientError.reloadOnceForChunkError();

expect(reload).not.toHaveBeenCalled();
});
});

it('still reloads when not embedded', () => {
jest.isolateModules(() => {
const clientError = jest.requireActual('../util/client-error');

clientError.reloadOnceForChunkError();

expect(reload).toHaveBeenCalledTimes(1);
});
});
});

// The wiring every entry point relies on, and the part a unit test of the pieces does not cover:
// which property of which event carries the error.
describe('installChunkErrorHandling', () => {
let reload: jest.Mock;
let installed: Array<[string, EventListener]>;

// window outlives each test, so the listeners have to be taken back off it — otherwise every
// later test fires through the handlers of the earlier ones as well.
function install(embed = false): void {
const add = window.addEventListener.bind(window);
jest.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => {
installed.push([type, listener as EventListener]);
add(type, listener, options);
});

jest.isolateModules(() => {
const clientError = jest.requireActual('../util/client-error');
if (embed) clientError.markEmbedded();
clientError.installChunkErrorHandling();
});
}

function fire(type: 'error' | 'unhandledrejection', props: Record<string, unknown>): void {
window.dispatchEvent(Object.assign(new Event(type), props));
}

// The test environment registers listeners of its own, so only the two channels matter here.
function ourListeners(): string[] {
return installed.map(([type]) => type).filter((type) => ['error', 'unhandledrejection'].includes(type));
}

beforeEach(() => {
localStorage.clear();
installed = [];
global.fetch = jest.fn().mockResolvedValue({ ok: true }) as jest.Mock;
reload = jest.fn();
Object.defineProperty(window, 'location', {
value: { ...window.location, pathname: '/buy', reload },
writable: true,
});
});

afterEach(() => {
installed.forEach(([type, listener]) => window.removeEventListener(type, listener));
jest.restoreAllMocks();
});

it('recovers a chunk failure reported as an error event', () => {
install();

fire('error', { error: Object.assign(new Error('Loading chunk 42 failed'), { name: 'ChunkLoadError' }) });

expect(reload).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledTimes(1);
});

// Some engines deliver only the message, with no error object attached.
it('recovers a chunk failure carried as a bare message', () => {
install();

fire('error', { message: 'Loading chunk 42 failed' });

expect(reload).toHaveBeenCalledTimes(1);
});

it('recovers a chunk failure from a rejected promise', () => {
install();

fire('unhandledrejection', { reason: new Error('Loading chunk 42 failed') });

expect(reload).toHaveBeenCalledTimes(1);
});

it('ignores an error that is not a chunk failure', () => {
install();

fire('error', { error: new Error('Cannot read properties of undefined') });

expect(reload).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});

// These listeners are page-wide, and embedded the page is the host's. A host running its own
// bundler would have its chunk failures filed as ours. Asserted on the registration rather than
// by firing an event, because an error event with nothing listening fails the test run itself.
it('installs no listeners when embedded', () => {
install(true);

expect(ourListeners()).toHaveLength(0);
});

it('installs listeners for both channels when not embedded', () => {
install();

expect(ourListeners().sort()).toEqual(['error', 'unhandledrejection']);
});
});
Loading
Loading