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
37 changes: 35 additions & 2 deletions rspack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,40 @@ const workerConfig: Configuration = {
context: staticPrefix,
experiments: appConfig.experiments,
lazyCompilation: appConfig.lazyCompilation,
module: appConfig.module,
module: {
rules: [
{
test: /\.ts$/,
// core-js: Avoids recompiling core-js based on usage imports
// compiled via swc.
exclude: /node_modules[\\/](core-js)/,
loader: 'builtin:swc-loader',
options: {
env: {
mode: 'usage',
// https://rspack.rs/guide/features/builtin-swc-loader#polyfill-injection
coreJs: '3.45.0',
targets: packageJson.browserslist.production,
shippedProposals: true,
},
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
development: DEV_MODE,
refresh: false,
},
},
},
isModule: 'unknown',
},
},
],
},
plugins: [
/**
* Without this, webpack will chunk the locales but attempt to load them all
Expand All @@ -626,7 +659,7 @@ const workerConfig: Configuration = {
resolve: appConfig.resolve,
// Don't clean: app's compiler owns cleaning `dist` (see its `clean.keep`).
output: {...appConfig.output, clean: false},
optimization: appConfig.optimization,
optimization: {...appConfig.optimization, runtimeChunk: false},
devtool: appConfig.devtool,
};

Expand Down
2 changes: 0 additions & 2 deletions static/app/bootstrap/initializeApp.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import './legacyTwitterBootstrap';
import './exportGlobals';

import {registerWorker} from 'sentry/serviceWorker/client/register';
import type {Config} from 'sentry/types/system';
import {metric} from 'sentry/utils/analytics';

Expand All @@ -21,5 +20,4 @@ export function initializeApp(config: Config) {
metric.mark({name: 'sentry-app-init'});
renderOnDomReady(renderMain);
processInitQueue();
registerWorker();
}
47 changes: 25 additions & 22 deletions static/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {SENTRY_RELEASE_VERSION} from 'sentry/constants/sdk';
import {preload} from 'sentry/router/preload';
import {RouteConfigProvider} from 'sentry/router/routeConfigContext';
import {routes} from 'sentry/router/routes';
import {ServiceWorkerProvider} from 'sentry/serviceWorker/client/serviceWorkerContext';
import {createReactRouter3Navigate} from 'sentry/utils/useNavigate';

function buildRouter() {
Expand All @@ -39,28 +40,30 @@ export function Main() {
<AppQueryClientProvider>
<DocumentTitleManager>
<FrontendVersionProvider releaseVersion={SENTRY_RELEASE_VERSION ?? null}>
<ThemeAndStyleProvider>
<NuqsAdapter defaultOptions={{shallow: false}}>
<CommandPaletteProvider>
<RouteConfigProvider value={router.routes}>
<RouterProvider router={router} />
</RouteConfigProvider>
</CommandPaletteProvider>
</NuqsAdapter>
{USE_TANSTACK_DEVTOOL && (
<TanStackDevtools
config={{position: 'bottom-right'}}
plugins={[
{
name: 'TanStack Query',
render: <ReactQueryDevtoolsPanel />,
},
formDevtoolsPlugin(),
pacerDevtoolsPlugin(),
]}
/>
)}
</ThemeAndStyleProvider>
<ServiceWorkerProvider>
<ThemeAndStyleProvider>
<NuqsAdapter defaultOptions={{shallow: false}}>
<CommandPaletteProvider>
<RouteConfigProvider value={router.routes}>
<RouterProvider router={router} />
</RouteConfigProvider>
</CommandPaletteProvider>
</NuqsAdapter>
{USE_TANSTACK_DEVTOOL && (
<TanStackDevtools
config={{position: 'bottom-right'}}
plugins={[
{
name: 'TanStack Query',
render: <ReactQueryDevtoolsPanel />,
},
formDevtoolsPlugin(),
pacerDevtoolsPlugin(),
]}
/>
)}
</ThemeAndStyleProvider>
</ServiceWorkerProvider>
</FrontendVersionProvider>
</DocumentTitleManager>
</AppQueryClientProvider>
Expand Down
57 changes: 0 additions & 57 deletions static/app/serviceWorker/client/register.ts

This file was deleted.

23 changes: 23 additions & 0 deletions static/app/serviceWorker/client/serviceWorker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {Button} from '@sentry/scraps/button';
import {Flex, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';

import {useServiceWorker} from 'sentry/serviceWorker/client/serviceWorkerContext';
import * as Storybook from 'sentry/stories';

export default Storybook.story('ServiceWorker', story => {
story('Basic Events', () => {
const {controller} = useServiceWorker();

return (
<Stack gap="md">
<Flex>
<Button onClick={() => controller.postMessage({name: 'ping', type: 'event'})}>
Send Ping event
</Button>
</Flex>
<Text>Look in the console for the ping event.</Text>
</Stack>
);
});
});
136 changes: 136 additions & 0 deletions static/app/serviceWorker/client/serviceWorkerContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {createContext, useContext, useEffect} from 'react';
import * as Sentry from '@sentry/react';

import {useFrontendVersion} from 'sentry/components/frontendVersionContext';
import {ServiceWorkerController} from 'sentry/serviceWorker/client/serviceWorkerInterface';

const DEBUG_LOGGING = false;

function log(message: string, options?: Sentry.metrics.MetricOptions) {
Sentry.metrics.count(`service-worker.register.${message}`, 1, options);
if (DEBUG_LOGGING) {
// eslint-disable-next-line no-console
console.log(`service-worker.register.${message}`);
}
}

function getWorkerUrl(): string {
return window.__SENTRY_DEV_UI ? '/entrypoints/service-worker.js' : '/service-worker.js';
}

const Context = createContext({
controller: new ServiceWorkerController(),
});

export function ServiceWorkerProvider({children}: {children: React.ReactNode}) {
const context = useContext(Context);

useRegisterServiceWorker();
useServiceWorkerUpdateCheck();
useLogControllerChangeEvent();

return <Context.Provider value={context}>{children}</Context.Provider>;
}

/**
* @public
* @returns The service worker context.
* @example
* const {controller} = useServiceWorker();
* controller.postMessage({name: 'ping', type: 'event'});
*/
export function useServiceWorker() {
return useContext(Context);
}

/**
* Register a service worker and send event `worker.init` to the newest worker
* available.
*/
function useRegisterServiceWorker() {
useEffect(() => {
if (!('serviceWorker' in navigator)) {
log('not-supported');
return;
}

navigator.serviceWorker
// https://rspack.rs/guide/features/web-workers
.register(getWorkerUrl(), {scope: '/'})
.then(registration => {
log('registered', {
attributes: {
// An old version could be active while the new instance is incoming
active: registration.active ? 'true' : 'false',
// The new instance should be `installing` to start, and should skip
// `waiting` to become `active` soon.
installing: registration.installing ? 'true' : 'false',
waiting: registration.waiting ? 'true' : 'false',
},
});

const worker = registration.installing;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this right? installation property returns instance of a worker?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ya, it returns the worker that is in the 'installing' phase.

This is a good starting point imo: https://web.dev/articles/service-worker-lifecycle

But maybe i can distill it too:

  1. When you first visit a page there are no workers in any of these slots. The page is 'uncontrolled'
  2. The page calls navigator.serviceWorker.register(some_url) is called -> this downloads the script code, runs it in sw global scope (so you can setup event listeners), and triggers the install event
  3. If the event handler callback doesn't throw then the worker moves into waiting phase: the page is still 'uncontrolled'
  4. When the page is reloaded or a navigation happens the page becomes 'controlled' and the service worker can intercept fetch requests. It works this way for consistency within a page, sw caching and stuff is easy to mess up.
  5. When a new version of the sw is available it'll do steps 1 & 3 like normal, but it also waits for all clients of the old version to get closed. If you have multiple tabs open that'll keep the sw pinned to an old version because the new version wants to take over and own everything, but can't if there's an existing page that's still open.

In this PR i'm using postMessage() which doesn't depend on whether the page is 'controlled' or 'uncontrolled'. So really none of these steps above really have an effect on that, we just want to send our postMessage to the newest available worker version. This line in particular is just for logging that the newest version of the worker is spinning up fully.

There are a few lines in this PR that skip the waiting period and get the newest code loaded and activated as quickly as possible; skipWaiting() and claim() calls. Since we're not doing the fetch interception thing I'm not worried right now about consistency problems that can happen if a new worker version get activated on a page that's already rendered. In future we have useFrontendVersion() which can help us rotate all tabs into the newest version to avoid those kinds of problems as well.

worker?.addEventListener('statechange', () => {
log('statechange', {
attributes: {
state: worker.state,
},
});
});
})
.catch(error => {
log('error');
Sentry.captureException(error);
});
}, []);
}

function useServiceWorkerUpdateCheck() {
const {state} = useFrontendVersion();

useEffect(() => {
if (!('serviceWorker' in navigator) || state === 'current') {
return;
}
Comment thread
ryan953 marked this conversation as resolved.

// A long-lived tab only learns about a new worker on the browser's
// infrequent periodic update check. Re-check whenever the tab becomes
// visible so a fresh deploy is picked up (trigger: install -> activate ->
// clients.claim -> controllerchange) as soon as the user returns to it.
const checkForUpdate = () => {
if (document.visibilityState !== 'visible') {
return;
}
log('update-check');
navigator.serviceWorker.ready
.then(registration => registration.update())
.catch(error => {
Sentry.captureException(error);
});
};

document.addEventListener('visibilitychange', checkForUpdate);
return () => {
document.removeEventListener('visibilitychange', checkForUpdate);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale SW update never runs

Medium Severity

useServiceWorkerUpdateCheck re-runs when frontend version state is not current, but it only registers a visibilitychange listener and never invokes checkForUpdate on mount. If the tab stays visible when state becomes stale (for example via the five-minute poll), registration.update() is not called until the user hides and re-shows the tab, delaying the new service worker for long-lived sessions.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6b77834. Configure here.

}, [state]);
}

function useLogControllerChangeEvent() {
useEffect(() => {
if (!('serviceWorker' in navigator)) {
return;
}

// Log whenever controllerchange happens, which means we have a new worker
// ready to listen to handle fetch and push requests.
// This event means that users have multiple tabs open at the same time, and
// the new workers are taking control of the pages.
const handler = () => log('controllerchange');

navigator.serviceWorker.addEventListener('controllerchange', handler);
return () => {
navigator.serviceWorker.removeEventListener('controllerchange', handler);
};
}, []);
}
47 changes: 47 additions & 0 deletions static/app/serviceWorker/client/serviceWorkerInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as Sentry from '@sentry/react';

import type {EventMessage} from 'sentry/serviceWorker/types';

/**
* Sends messages from the page to the service worker.
*
* Every message must be an `EventMessage` (see `sentry/serviceWorker/types`).
* Use to notify the worker that something has happened on the page.
*/
export class ServiceWorkerController {
/**
* Find the service worker that this page should send messages to.
*
* Since we only need to post messages & not intercept network requests,
* we use `navigator.serviceWorker.ready` to get the active worker.
* We don't need to wait for the page to be controlled by the worker.
*/
private async getWorker(): Promise<ServiceWorker | null> {
if (!('serviceWorker' in navigator)) {
return null;
}
// For more read: https://web.dev/articles/service-worker-lifecycle
const registration = await navigator.serviceWorker.ready;
return registration.installing ?? registration.waiting ?? registration.active;
Comment thread
ryan953 marked this conversation as resolved.
Comment thread
ryan953 marked this conversation as resolved.
}

public postMessage(message: EventMessage): Promise<unknown> {
return Sentry.startSpan(
{
name: 'service-worker.controller',
op: 'sw.postMessage',
attributes: {type: message.type, name: message.name},
},
async () => {
const worker = await this.getWorker();
if (!worker) {
return;
}
if (message.type === 'event') {
worker.postMessage(message);
return;
}
}
);
}
}
4 changes: 4 additions & 0 deletions static/app/serviceWorker/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type EventMessage = {
name: 'ping';
type: 'event';
};
Loading
Loading