diff --git a/.changeset/ten-knives-tickle.md b/.changeset/ten-knives-tickle.md new file mode 100644 index 0000000000..a090f4a0c8 --- /dev/null +++ b/.changeset/ten-knives-tickle.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Workers context (e.g. `waitUntil`) is now scoped to the current request instead of globally available. diff --git a/packages/hydrogen/src/entry-server.tsx b/packages/hydrogen/src/entry-server.tsx index 0b40a90f76..41d9b34f79 100644 --- a/packages/hydrogen/src/entry-server.tsx +++ b/packages/hydrogen/src/entry-server.tsx @@ -16,7 +16,10 @@ import type { } from './types'; import {Html, applyHtmlHead} from './framework/Hydration/Html'; import {ServerComponentResponse} from './framework/Hydration/ServerComponentResponse.server'; -import {ServerComponentRequest} from './framework/Hydration/ServerComponentRequest.server'; +import { + RuntimeContext, + ServerComponentRequest, +} from './framework/Hydration/ServerComponentRequest.server'; import { preloadRequestCacheData, ServerRequestProvider, @@ -30,7 +33,7 @@ import { } from './utilities/apiRoutes'; import {ServerPropsProvider} from './foundation/ServerPropsProvider'; import {isBotUA} from './utilities/bot-ua'; -import {setContext, setCache, RuntimeContext} from './framework/runtime'; +import {setCache} from './framework/runtime'; import { ssrRenderToPipeableStream, ssrRenderToReadableStream, @@ -124,8 +127,8 @@ export const renderHydrogen = (App: any) => { /** * Inject the cache & context into the module loader so we can pull it out for subrequests. */ + request.ctx.runtime = context; setCache(cache); - setContext(context); if ( url.pathname === EVENT_PATHNAME || diff --git a/packages/hydrogen/src/foundation/useQuery/hooks.ts b/packages/hydrogen/src/foundation/useQuery/hooks.ts index 331e23832b..d1df483419 100644 --- a/packages/hydrogen/src/foundation/useQuery/hooks.ts +++ b/packages/hydrogen/src/foundation/useQuery/hooks.ts @@ -11,7 +11,6 @@ import { isStale, setItemInCache, } from '../../framework/cache-sub-request'; -import {runDelayedFunction} from '../../framework/runtime'; import {useRequestCacheData, useServerRequest} from '../ServerRequestProvider'; import {CacheSeconds} from '../../framework/CachingStrategy'; @@ -111,29 +110,35 @@ function cachedQueryFnBuilder( if (isStale(key, response)) { const lockKey = ['lock', ...(typeof key === 'string' ? [key] : key)]; - runDelayedFunction(async () => { - const lockExists = await getItemFromCache(lockKey); - if (lockExists) return; - - await setItemInCache( - lockKey, - true, - CacheSeconds({ - maxAge: 10, - }) - ); - try { - const output = await generateNewOutput(); - - if (shouldCacheResponse(output)) { - await setItemInCache(key, output, resolvedQueryOptions?.cache); + // Run revalidation asynchronously + const revalidatingPromise = getItemFromCache(lockKey).then( + async (lockExists) => { + if (lockExists) return; + + await setItemInCache( + lockKey, + true, + CacheSeconds({ + maxAge: 10, + }) + ); + + try { + const output = await generateNewOutput(); + + if (shouldCacheResponse(output)) { + await setItemInCache(key, output, resolvedQueryOptions?.cache); + } + } catch (e: any) { + log.error(`Error generating async response: ${e.message}`); + } finally { + await deleteItemFromCache(lockKey); } - } catch (e: any) { - log.error(`Error generating async response: ${e.message}`); - } finally { - await deleteItemFromCache(lockKey); } - }); + ); + + // Asynchronously wait for it in workers + request.ctx.runtime?.waitUntil?.(revalidatingPromise); } return output; @@ -145,9 +150,13 @@ function cachedQueryFnBuilder( * Important: Do this async */ if (shouldCacheResponse(newOutput)) { - runDelayedFunction(() => - setItemInCache(key, newOutput, resolvedQueryOptions?.cache) + const setItemInCachePromise = setItemInCache( + key, + newOutput, + resolvedQueryOptions?.cache ); + + request.ctx.runtime?.waitUntil?.(setItemInCachePromise); } collectQueryCacheControlHeaders( diff --git a/packages/hydrogen/src/framework/Hydration/ServerComponentRequest.server.ts b/packages/hydrogen/src/framework/Hydration/ServerComponentRequest.server.ts index 6a01b7a5b9..37fbb2f681 100644 --- a/packages/hydrogen/src/framework/Hydration/ServerComponentRequest.server.ts +++ b/packages/hydrogen/src/framework/Hydration/ServerComponentRequest.server.ts @@ -13,6 +13,10 @@ import {RSC_PATHNAME} from '../../constants'; import {SessionSyncApi} from '../../foundation/session/session'; import {parseJSON} from '../../utilities/parse'; +export interface RuntimeContext { + waitUntil: (fn: Promise) => void; +} + export type PreloadQueryEntry = { key: QueryKey; fetcher: () => Promise; @@ -64,6 +68,7 @@ export class ServerComponentRequest extends Request { router: RouterContextData; buyerIpHeader?: string; session?: SessionSyncApi; + runtime?: RuntimeContext; [key: string]: any; }; diff --git a/packages/hydrogen/src/framework/runtime.ts b/packages/hydrogen/src/framework/runtime.ts index 83563ff583..5e389e9294 100644 --- a/packages/hydrogen/src/framework/runtime.ts +++ b/packages/hydrogen/src/framework/runtime.ts @@ -1,25 +1,7 @@ declare namespace globalThis { - let __ctx: RuntimeContext | undefined; let __cache: Cache | undefined; } -export interface RuntimeContext { - waitUntil: (fn: Promise) => void; -} - -/** - * Set a global runtime context for the current request. - * This is used to encapsulate things like: - * - `waitUntil()` to run promises after request has ended - */ -export function setContext(ctx?: RuntimeContext) { - globalThis.__ctx = ctx; -} - -export function getContext() { - return globalThis.__ctx; -} - export function setCache(cache?: Cache) { globalThis.__cache = cache; } @@ -27,16 +9,3 @@ export function setCache(cache?: Cache) { export function getCache(): Cache | undefined { return globalThis.__cache; } - -export function runDelayedFunction(fn: () => Promise) { - const context = getContext(); - - /** - * Runtimes (Oxygen, Node.js) might not have this. - */ - if (!context?.waitUntil) { - return fn(); - } - - return context.waitUntil(fn()); -} diff --git a/packages/hydrogen/src/hooks/useShopQuery/tests/useShopQuery.test.tsx b/packages/hydrogen/src/hooks/useShopQuery/tests/useShopQuery.test.tsx index b19e30ae2c..1d0cb73c6c 100644 --- a/packages/hydrogen/src/hooks/useShopQuery/tests/useShopQuery.test.tsx +++ b/packages/hydrogen/src/hooks/useShopQuery/tests/useShopQuery.test.tsx @@ -3,7 +3,7 @@ import {useShopQuery} from '../hooks'; import {mountWithProviders} from '../../../utilities/tests/shopifyMount'; import {ServerRequestProvider} from '../../../foundation/ServerRequestProvider'; import {ServerComponentRequest} from '../../../framework/Hydration/ServerComponentRequest.server'; -import {setCache, setContext} from '../../../framework/runtime'; +import {setCache} from '../../../framework/runtime'; import {InMemoryCache} from '../../../framework/cache/in-memory'; jest.mock('../../../foundation/ssr-interop', () => { @@ -13,6 +13,8 @@ jest.mock('../../../foundation/ssr-interop', () => { }; }); +let waitUntilPromises = [] as Array>; + function mountComponent() { function Component() { const result = useShopQuery({query: 'query { test {} }'}); @@ -23,6 +25,10 @@ function mountComponent() { new Request('https://example.com') ); + request.ctx.runtime = { + waitUntil: (p: Promise) => waitUntilPromises.push(p), + }; + return mountWithProviders( @@ -35,7 +41,6 @@ function mountComponent() { describe('useShopQuery', () => { const originalFetch = globalThis.fetch; const mockedFetch = jest.fn(originalFetch); - let waitUntilPromises: Array>; let cache: Cache; let consoleErrorSpy: jest.SpyInstance; @@ -45,7 +50,6 @@ describe('useShopQuery', () => { beforeEach(() => { waitUntilPromises = []; - setContext({waitUntil: (p: Promise) => waitUntilPromises.push(p)}); cache = new InMemoryCache() as unknown as Cache; setCache(cache); consoleErrorSpy = jest.spyOn(console, 'error'); @@ -58,7 +62,6 @@ describe('useShopQuery', () => { afterAll(() => { globalThis.fetch = originalFetch; - setContext(undefined); setCache(undefined); });