diff --git a/e2e/tests/feature-page.spec.ts b/e2e/tests/feature-page.spec.ts index 76139379e..96613a272 100644 --- a/e2e/tests/feature-page.spec.ts +++ b/e2e/tests/feature-page.spec.ts @@ -169,3 +169,51 @@ test('date range changes are preserved in the URL', async ({page}) => { const endDateInputElement3 = endDateSelector3.locator('input'); expect(await endDateInputElement3.inputValue()).toBe(endDate); }); + +test('redirects for a moved feature', async ({page}) => { + await page.goto('http://localhost:5555/features/old-feature'); + + // Expect the URL to be updated to the new feature's URL. + await expect(page).toHaveURL( + 'http://localhost:5555/features/new-feature?redirected_from=old-feature', + ); + + // Expect the title and redirect banner to be correct. + await expect(page.locator('h1')).toHaveText('New Feature'); + await expect( + page.locator( + 'sl-alert:has-text("You have been redirected from an old feature ID")', + ), + ).toBeVisible(); + + // Wait for charts to load to avoid flakiness in the screenshot. + await page.waitForSelector('#feature-wpt-implementation-progress-0-complete'); + + // Take a screenshot for visual verification. + const pageContainer = page.locator('.page-container'); + await expect(pageContainer).toHaveScreenshot(); +}); + +test('shows gone page for a split feature', async ({page}) => { + await page.goto('http://localhost:5555/features/before-split-feature'); + + // Expect to be redirected to the 'feature-gone-split' page. + await expect(page).toHaveURL( + 'http://localhost:5555/errors-410/feature-gone-split?new_features=after-split-feature-1,after-split-feature-2', + ); + + // Assert that the content of the 410 page is correct. + await expect(page.locator('.new-results-header')).toContainText( + 'Please see the following new features', + ); + await expect( + page.locator('a[href="/features/after-split-feature-1"]'), + ).toBeVisible(); + await expect( + page.locator('a[href="/features/after-split-feature-2"]'), + ).toBeVisible(); + + // Take a screenshot for visual verification. + const pageContainer = page.locator('.container'); // Assuming a generic container for the error page. + await expect(pageContainer).toHaveScreenshot(); +}); diff --git a/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-chromium-linux.png b/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-chromium-linux.png new file mode 100644 index 000000000..3b2d458c2 Binary files /dev/null and b/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-chromium-linux.png differ diff --git a/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-firefox-linux.png b/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-firefox-linux.png new file mode 100644 index 000000000..06c43414d Binary files /dev/null and b/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-firefox-linux.png differ diff --git a/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-webkit-linux.png b/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-webkit-linux.png new file mode 100644 index 000000000..2758554b3 Binary files /dev/null and b/e2e/tests/feature-page.spec.ts-snapshots/redirects-for-a-moved-feature-1-webkit-linux.png differ diff --git a/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-chromium-linux.png b/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-chromium-linux.png new file mode 100644 index 000000000..e2173c417 Binary files /dev/null and b/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-chromium-linux.png differ diff --git a/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-firefox-linux.png b/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-firefox-linux.png new file mode 100644 index 000000000..b87b11889 Binary files /dev/null and b/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-firefox-linux.png differ diff --git a/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-webkit-linux.png b/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-webkit-linux.png new file mode 100644 index 000000000..5fa527097 Binary files /dev/null and b/e2e/tests/feature-page.spec.ts-snapshots/shows-gone-page-for-a-split-feature-1-webkit-linux.png differ diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e5f31b9c9..981aece17 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -53,6 +53,10 @@ http { try_files $uri /index.html; } + location = /errors-410/feature-gone-split { + try_files $uri /index.html; + } + # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; add_header X-Content-Type-Options "nosniff"; diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts index 349cf0880..b5cbbd515 100644 --- a/frontend/src/static/js/api/client.ts +++ b/frontend/src/static/js/api/client.ts @@ -21,7 +21,11 @@ import createClient, { ParseAsResponse, } from 'openapi-fetch'; import {type components, type paths} from 'webstatus.dev-backend'; -import {createAPIError} from './errors.js'; +import { + createAPIError, + FeatureGoneSplitError, + FeatureMovedError, +} from './errors.js'; import { MediaType, @@ -302,17 +306,37 @@ export class APIClient { const qsParams: paths['/v1/features/{feature_id}']['get']['parameters']['query'] = {}; if (wptMetricView) qsParams.wpt_metric_view = wptMetricView; - const {data, error} = await this.client.GET('/v1/features/{feature_id}', { + const resp = await this.client.GET('/v1/features/{feature_id}', { ...temporaryFetchOptions, params: { path: {feature_id: featureId}, query: qsParams, }, }); - if (error !== undefined) { - throw createAPIError(error); + if (resp.error !== undefined) { + const data = resp.error; + if (resp.response.status === 410 && 'new_features' in data) { + // Type narrowing doesn't work. + // https://github.com/openapi-ts/openapi-typescript/issues/1723 + // We have to force it. + const featureGoneData = + data as components['schemas']['FeatureGoneError']; + throw new FeatureGoneSplitError( + resp.error.message, + featureGoneData.new_features.map(f => f.id), + ); + } + throw createAPIError(resp.error); } - return data; + if (resp.response.redirected) { + const featureId = resp.response.url.split('/').pop() || ''; + throw new FeatureMovedError( + 'redirected to feature', + featureId, + resp.data, + ); + } + return resp.data; } public async getFeatureMetadata( diff --git a/frontend/src/static/js/api/errors.ts b/frontend/src/static/js/api/errors.ts index 5bffa269c..9f282b9b4 100644 --- a/frontend/src/static/js/api/errors.ts +++ b/frontend/src/static/js/api/errors.ts @@ -1,3 +1,5 @@ +import {type components} from 'webstatus.dev-backend'; + /** * Copyright 2024 Google LLC * @@ -106,3 +108,27 @@ export class UnknownError extends ApiError { this.name = 'UnknownError'; } } + +export class FeatureGoneSplitError extends ApiError { + newFeatureIds: Array; + constructor(message: string, newFeatureIds: Array) { + super(message, 410); + this.name = 'FeatureGoneSplitError'; + this.newFeatureIds = newFeatureIds; + } +} + +export class FeatureMovedError extends ApiError { + newFeatureId: string; + feature: components['schemas']['Feature']; + constructor( + message: string, + newFeatureId: string, + feature: components['schemas']['Feature'], + ) { + super(message, 301); + this.name = 'FeatureMovedError'; + this.newFeatureId = newFeatureId; + this.feature = feature; + } +} diff --git a/frontend/src/static/js/components/test/webstatus-feature-gone-split-page.test.ts b/frontend/src/static/js/components/test/webstatus-feature-gone-split-page.test.ts new file mode 100644 index 000000000..f98aad0ac --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-feature-gone-split-page.test.ts @@ -0,0 +1,139 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, fixture, html} from '@open-wc/testing'; +import '../webstatus-feature-gone-split-page.js'; +import {WebstatusFeatureGoneSplitPage} from '../webstatus-feature-gone-split-page.js'; +import {Task} from '@lit/task'; +import {APIClient} from '../../contexts/api-client-context.js'; +import {GITHUB_REPO_ISSUE_LINK} from '../../utils/constants.js'; + +type NewFeature = {name: string; url: string}; + +describe('webstatus-feature-gone-split-page', () => { + const newFeatureIds = 'feature1,feature2'; + const mockNewFeatures: NewFeature[] = [ + {name: 'Feature One', url: '/features/feature1'}, + {name: 'Feature Two', url: '/features/feature2'}, + ]; + + it('renders the correct error message', async () => { + const component = await fixture( + html``, + ); + + expect( + component.shadowRoot + ?.querySelector('#error-status-code') + ?.textContent?.trim(), + ).to.equal('410'); + + expect( + component.shadowRoot + ?.querySelector('#error-headline') + ?.textContent?.trim(), + ).to.equal('Feature Gone'); + + expect( + component.shadowRoot + ?.querySelector('#error-detailed-message .error-message') + ?.textContent?.trim(), + ).to.equal('This feature has been split into multiple new features.'); + }); + + it('displays "Loading new features..." when the API request is pending', async () => { + const component = await createComponentWithMockedNewFeatures( + newFeatureIds, + [], + {stayPending: true}, + ); + + const loadingMessage = + component.shadowRoot?.querySelector('.loading-message'); + expect(loadingMessage).to.exist; + expect(loadingMessage?.textContent?.trim()).to.equal( + 'Loading new features...', + ); + }); + + it('renders new features when API returns results', async () => { + const component = await createComponentWithMockedNewFeatures( + newFeatureIds, + mockNewFeatures, + ); + + const featureList = + component.shadowRoot?.querySelectorAll('.feature-list li'); + expect(featureList?.length).to.equal(2); + expect(featureList?.[0]?.textContent?.trim()).to.equal('Feature One'); + expect(featureList?.[1]?.textContent?.trim()).to.equal('Feature Two'); + }); + + it('renders action buttons', async () => { + const component = await fixture(html` + + `); + + expect(component.shadowRoot?.querySelector('#error-action-home-btn')).to + .exist; + expect(component.shadowRoot?.querySelector('#error-action-report')).to + .exist; + }); + + it('report issue button links to GitHub', async () => { + const component = await fixture(html` + + `); + + const reportButton = component.shadowRoot?.querySelector( + '#error-action-report', + ); + expect(reportButton?.getAttribute('href')).to.equal(GITHUB_REPO_ISSUE_LINK); + }); + + async function createComponentWithMockedNewFeatures( + newFeatureIds: string, + mockData: NewFeature[], + options: {stayPending?: boolean} = {}, + ): Promise { + const component = await fixture(html` + + `); + + component._newFeatures = new Task<[APIClient, string], NewFeature[]>( + component, + { + args: () => [undefined as unknown as APIClient, newFeatureIds], + task: async () => { + if (options.stayPending) return new Promise(() => {}); + return mockData; + }, + }, + ); + + component._newFeatures.run(); + await component.updateComplete; + return component; + } +}); diff --git a/frontend/src/static/js/components/test/webstatus-feature-page.test.ts b/frontend/src/static/js/components/test/webstatus-feature-page.test.ts index 96306c608..49d6899b7 100644 --- a/frontend/src/static/js/components/test/webstatus-feature-page.test.ts +++ b/frontend/src/static/js/components/test/webstatus-feature-page.test.ts @@ -20,6 +20,7 @@ import '../webstatus-feature-page.js'; import sinon from 'sinon'; import {WPTRunMetric} from '../../api/client.js'; import {render} from 'lit'; +import {FeatureMovedError} from '../../api/errors.js'; describe('webstatus-feature-page', () => { let el: FeaturePage; @@ -479,4 +480,61 @@ describe('webstatus-feature-page', () => { expect(links[1].getAttribute('href')).to.equal('/features/other-feature'); }); }); + + describe('redirects', () => { + it('shows a redirect notice if the redirected_from URL parameter is present', async () => { + const redirectedEl = await fixture( + html``, + ); + await redirectedEl.updateComplete; + + const alert = redirectedEl.shadowRoot?.querySelector('sl-alert'); + expect(alert).to.not.be.null; + // Normalize whitespace to avoid issues with formatting in the template literal. + const text = alert?.textContent?.replace(/\s+/g, ' ').trim(); + expect(text).to.contain( + 'You have been redirected from an old feature ID (old-feature)', + ); + }); + + it('does not show a redirect notice if the URL parameter is not present', async () => { + const alert = el.shadowRoot?.querySelector('sl-alert'); + expect(alert).to.be.null; + }); + + it('handleMovedFeature updates the history and component state', async () => { + const pushStateSpy = sinon.spy(history, 'pushState'); + const newFeature = { + feature_id: 'new-feature', + name: 'New Feature', + description: 'A new feature', + browser_implementations: {}, + wpt: {}, + }; + const fakeError = new FeatureMovedError('foo', 'new-feature', newFeature); + + el.handleMovedFeature('old-feature', fakeError); + + expect(el.featureId).to.equal('new-feature'); + expect(el.oldFeatureId).to.equal('old-feature'); + expect(el.feature).to.deep.equal(newFeature); + + expect(pushStateSpy).to.have.been.calledWith( + null, + '', + '/features/new-feature?redirected_from=old-feature', + ); + + const canonical = document.head.querySelector('link[rel="canonical"]'); + expect(canonical).to.not.be.null; + expect(canonical?.getAttribute('href')).to.equal('/features/new-feature'); + expect(document.title).to.equal('New Feature'); + }); + }); }); diff --git a/frontend/src/static/js/components/webstatus-feature-gone-split-page.ts b/frontend/src/static/js/components/webstatus-feature-gone-split-page.ts new file mode 100644 index 000000000..e06215859 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-feature-gone-split-page.ts @@ -0,0 +1,217 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {LitElement, html, type TemplateResult, CSSResultGroup, css} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import {GITHUB_REPO_ISSUE_LINK} from '../utils/constants.js'; +import {consume} from '@lit/context'; +import {APIClient, apiClientContext} from '../contexts/api-client-context.js'; +import {Task} from '@lit/task'; +import {FeatureWPTMetricViewType} from '../api/client.js'; +import {formatFeaturePageUrl, getWPTMetricView} from '../utils/urls.js'; + +type NewFeature = {name: string; url: string}; + +@customElement('webstatus-feature-gone-split-page') +export class WebstatusFeatureGoneSplitPage extends LitElement { + _newFeatures?: Task<[APIClient, string], NewFeature[]>; + + @property({type: Object}) + location!: {search: string}; // Set by router. + + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + constructor() { + super(); + this._newFeatures = new Task<[APIClient, string], NewFeature[]>(this, { + args: () => { + const params = new URLSearchParams(this.location.search); + const newFeatures = params.get('new_features') || ''; + return [this.apiClient, newFeatures]; + }, + task: async ([apiClient, newFeatures]) => { + if (!newFeatures) return []; + const featureIds = newFeatures.split(','); + const wptMetricView = getWPTMetricView( + this.location, + ) as FeatureWPTMetricViewType; + const features = await Promise.all( + featureIds.map(id => apiClient.getFeature(id, wptMetricView)), + ); + return features.map(f => ({ + name: f.name, + url: formatFeaturePageUrl(f), + })); + }, + }); + } + + static get styles(): CSSResultGroup { + return [ + SHARED_STYLES, + css` + #error-container { + width: 100%; + height: 100%; + flex-direction: column; + justify-content: center; + align-items: center; + display: inline-flex; + gap: 32px; + } + #error-header { + align-self: stretch; + height: 108px; + flex-direction: column; + justify-content: flex-start; + align-items: center; + gap: 12px; + display: flex; + } + #error-status-code { + color: #2563eb; + font-size: 15px; + font-weight: 700; + line-height: 22.5px; + word-wrap: break-word; + } + #error-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--content-padding); + } + #error-headline { + color: #1d2430; + font-size: 32px; + font-weight: 700; + word-wrap: break-word; + } + #error-detailed-message { + font-size: 15px; + font-weight: 400; + line-height: 22.5px; + word-wrap: break-word; + } + + .error-message { + color: #6c7381; + } + .new-features-container { + text-align: left; + padding: 12px; + max-width: 400px; + } + .new-results-header { + color: #1a1a1a; + font-weight: 500; + margin-bottom: 6px; + } + .feature-list { + list-style: none; + padding: 0; + margin: 0; + } + .feature-list li { + padding: 6px 0; + } + .feature-list li a { + text-decoration: none; + color: #007bff; + font-weight: 500; + } + .feature-list li a:hover { + text-decoration: underline; + color: #0056b3; + } + `, + ]; + } + + private _renderErrorHeader(): TemplateResult { + return html` +
+
410
+
Feature Gone
+
+ + This feature has been split into multiple new features. + +
+
+ `; + } + + private _renderNewFeatures( + features: NewFeature[] | undefined, + ): TemplateResult { + if (!features?.length) { + return html`

No new features found.

`; + } + return html` +
+

Please see the following new features:

+ +
+ `; + } + + private _renderActionButtons(): TemplateResult { + return html` +
+ + Go back home + + + + Report an issue + +
+ `; + } + + protected render(): TemplateResult { + return html` +
+ ${this._renderErrorHeader()} + ${this._newFeatures?.render({ + initial: () => + html`

Loading new features...

`, + pending: () => + html`

Loading new features...

`, + complete: features => + html` ${this._renderNewFeatures(features)} + ${this._renderActionButtons()}`, + error: error => + html`

+ Oops, something went wrong: ${error} +

`, + })} +
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-feature-page.ts b/frontend/src/static/js/components/webstatus-feature-page.ts index f05728f27..7df2b0554 100644 --- a/frontend/src/static/js/components/webstatus-feature-page.ts +++ b/frontend/src/static/js/components/webstatus-feature-page.ts @@ -49,7 +49,11 @@ import {BaseChartsPage} from './webstatus-base-charts-page.js'; import './webstatus-feature-wpt-progress-chart-panel.js'; import './webstatus-feature-usage-chart-panel.js'; import {DataFetchedEvent} from './webstatus-line-chart-panel.js'; -import {NotFoundError} from '../api/errors.js'; +import { + FeatureGoneSplitError, + FeatureMovedError, + NotFoundError, +} from '../api/errors.js'; import {formatDeveloperUpvotesMessages} from '../utils/format.js'; // CanIUseData is a slimmed down interface of the data returned from the API. interface CanIUseData { @@ -77,6 +81,15 @@ export class FeaturePage extends BaseChartsPage { @state() featureMetadata?: {can_i_use?: CanIUseData; description?: string} | undefined; + @state() + oldFeatureId?: string; + + @state() + _isMoved = false; + + @state() + _newFeatureId?: string; + featureId!: string; // Members that are used for testing with sinon. @@ -214,16 +227,28 @@ export class FeaturePage extends BaseChartsPage { this._loadingTask = new Task(this, { args: () => [this.apiClient, this.featureId], task: async ([apiClient, featureId]) => { - if (typeof apiClient === 'object' && typeof featureId === 'string') { + if (typeof apiClient !== 'object' || typeof featureId !== 'string') { + return Promise.reject('api client and/or featureId not set'); + } + try { const wptMetricView = getWPTMetricView( this.location, ) as FeatureWPTMetricViewType; - this.feature = await apiClient.getFeature(featureId, wptMetricView); - return this.feature; + const feature = await apiClient.getFeature(featureId, wptMetricView); + this.feature = feature; + return feature; + } catch (error) { + if (error instanceof FeatureMovedError) { + this.handleMovedFeature(featureId, error); + // The task can now complete successfully with the new feature data. + return error.feature; + } + // For other errors, re-throw them to be handled by onError. + throw error; } - return Promise.reject('api client and/or featureId not set'); }, onError: async error => { + // FeatureMovedError is now handled in the task, so it won't appear here. if (error instanceof NotFoundError) { const queryParam = this.featureId ? `?q=${this.featureId}` : ''; @@ -232,6 +257,12 @@ export class FeaturePage extends BaseChartsPage { // For now use the window href and revisit when navigateToUrl // is move to another location. window.location.href = `/errors-404/feature-not-found${queryParam}`; + } else if (error instanceof FeatureGoneSplitError) { + const newFeatureIds = error.newFeatureIds.join(','); + const queryParam = newFeatureIds + ? `?new_features=${newFeatureIds}` + : ''; + window.location.href = `/errors-410/feature-gone-split${queryParam}`; } else { console.error('Unexpected error in _loadingTask:', error); } @@ -249,10 +280,37 @@ export class FeaturePage extends BaseChartsPage { }); } + handleMovedFeature(oldFeatureId: string, error: FeatureMovedError) { + const newFeature = error.feature; + const newFeatureId = error.newFeatureId; + + // Set component state to render the new feature. + this.feature = newFeature; + this.featureId = newFeatureId; + this.oldFeatureId = oldFeatureId; // Used to show a redirect notice. + + // Update browser URL and history. + const newUrl = `/features/${newFeatureId}?redirected_from=${oldFeatureId}`; + history.pushState(null, '', newUrl); + + // Update the canonical URL in the document head for SEO. + document.head.querySelector('link[rel="canonical"]')?.remove(); + const canonical = document.createElement('link'); + canonical.rel = 'canonical'; + // The canonical URL should be clean, without the 'redirected_from' param. + canonical.href = `/features/${newFeatureId}`; + document.head.appendChild(canonical); + + // Update the page title. + document.title = newFeature.name || newFeatureId; + } + override async firstUpdated(): Promise { await super.firstUpdated(); this.featureId = this.location.params['featureId']?.toString() || 'undefined'; + const urlParams = new URLSearchParams(this.location.search); + this.oldFeatureId = urlParams.get('redirected_from') || undefined; } render(): TemplateResult { @@ -266,6 +324,20 @@ export class FeaturePage extends BaseChartsPage { `; } + renderRedirectNotice(): TemplateResult { + if (!this.oldFeatureId) { + return html`${nothing}`; + } + + return html` + + + You have been redirected from an old feature ID + (${this.oldFeatureId}). + + `; + } + renderCrumbs(): TemplateResult { const overviewUrl = formatOverviewPageUrl(this.location); const canonicalFeatureUrl = this.feature @@ -642,6 +714,7 @@ export class FeaturePage extends BaseChartsPage { return html`
+ ${this.renderRedirectNotice()} ${this.renderDiscouragedNotice(this.feature?.discouraged)}
${this.renderCrumbs()} diff --git a/frontend/src/static/js/utils/app-router.ts b/frontend/src/static/js/utils/app-router.ts index 85d34b701..96f316b87 100644 --- a/frontend/src/static/js/utils/app-router.ts +++ b/frontend/src/static/js/utils/app-router.ts @@ -20,6 +20,7 @@ import '../components/webstatus-overview-page.js'; import '../components/webstatus-feature-page.js'; import '../components/webstatus-stats-page.js'; import '../components/webstatus-notfound-error-page.js'; +import '../components/webstatus-feature-gone-split-page.js'; export const initRouter = async (element: HTMLElement): Promise => { const router = new Router(element); @@ -36,6 +37,10 @@ export const initRouter = async (element: HTMLElement): Promise => { component: 'webstatus-stats-page', path: '/stats', }, + { + component: 'webstatus-feature-gone-split-page', + path: '/errors-410/feature-gone-split', + }, { path: '(.*)', component: 'webstatus-notfound-error-page', diff --git a/lib/gcpspanner/split_web_features.go b/lib/gcpspanner/split_web_features.go index e53c80802..d1ecceb9d 100644 --- a/lib/gcpspanner/split_web_features.go +++ b/lib/gcpspanner/split_web_features.go @@ -17,6 +17,7 @@ package gcpspanner import ( "context" "log/slog" + "slices" "cloud.google.com/go/spanner" ) @@ -143,5 +144,16 @@ func (m splitWebFeatureByOriginalKeyMapper) SelectOne(featureKey string) spanner // Other errors should be investigated and handled appropriately. func (c *Client) GetSplitWebFeatureByOriginalFeatureKey( ctx context.Context, featureKey string) (*SplitWebFeature, error) { - return newEntityReader[splitWebFeatureByOriginalKeyMapper, SplitWebFeature, string](c).readRowByKey(ctx, featureKey) + splitWebFeature, err := newEntityReader[ + splitWebFeatureByOriginalKeyMapper, + SplitWebFeature, + string, + ](c).readRowByKey(ctx, featureKey) + if err != nil { + return nil, err + } + // Sort for stable output. + slices.Sort(splitWebFeature.TargetFeatureKeys) + + return splitWebFeature, nil }