From 9f4c1c0f44ecd5b4082ba292d71e9b044610d4a7 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Fri, 15 May 2026 17:26:50 +0200 Subject: [PATCH 1/2] fix(core): allow duplicate qwik via shared singletons --- .changeset/shared-singletons-global.md | 7 +++ packages/qwik/src/core/index.ts | 16 +------ .../qwik/src/core/preloader/bundle-graph.ts | 8 ++-- packages/qwik/src/core/preloader/queue.ts | 4 +- .../qwik/src/core/preloader/queue.unit.ts | 18 ++++---- .../qwik/src/core/shared/platform/platform.ts | 8 +++- .../src/core/shared/platform/platform.unit.ts | 11 +++-- .../qwik/src/core/shared/qrl/qrl-class.ts | 8 ++-- packages/qwik/src/core/shared/qrl/qrl.ts | 11 +++-- .../qwik/src/core/shared/serdes/allocate.ts | 11 ++++- .../src/core/shared/serdes/deser-proxy.ts | 6 ++- .../qwik/src/core/shared/serdes/inflate.ts | 10 +++- packages/qwik/src/core/shared/singletons.ts | 46 +++++++++++++++++++ packages/qwik/src/core/use/use-locale.ts | 39 ++++++++++------ packages/qwik/src/server/platform.ts | 8 ++-- packages/qwik/src/server/platform.unit.ts | 12 +++-- packages/qwik/src/server/qwik-copy.ts | 1 + packages/qwik/src/testing/platform.ts | 3 +- scripts/submodule-optimizer.ts | 7 ++- scripts/submodule-server.ts | 1 + 20 files changed, 164 insertions(+), 71 deletions(-) create mode 100644 .changeset/shared-singletons-global.md create mode 100644 packages/qwik/src/core/shared/singletons.ts diff --git a/.changeset/shared-singletons-global.md b/.changeset/shared-singletons-global.md new file mode 100644 index 00000000000..41090ace6ed --- /dev/null +++ b/.changeset/shared-singletons-global.md @@ -0,0 +1,7 @@ +--- +'@qwik.dev/core': patch +--- + +FIX: Move singletons to `globalThis.__qwik__`. Same-version coexistence is fine and gets to share the singleton state. On the server, only allow one Qwik version per process. + +This is a necessary step to allow Qwik third party libraries to stay external on the server. diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index d05d435b0a1..53cf24e3fac 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -1,19 +1,7 @@ ////////////////////////////////////////////////////////////////////////////////////////// -// Protect against duplicate imports +// Server-side singleton registry — also runs the duplicate-Qwik version check on import ////////////////////////////////////////////////////////////////////////////////////////// -import { QError, qError } from '../server/qwik-copy'; -import { version } from './version'; - -if ((globalThis as any).__qwik) { - qError(QError.duplicateQwik, [(globalThis as any).__qwik, version]); -} -(globalThis as any).__qwik = version; - -if (import.meta.hot) { - import.meta.hot.dispose(() => { - (globalThis as any).__qwik = undefined; - }); -} +import './shared/singletons'; ////////////////////////////////////////////////////////////////////////////////////////// // Developer Core API diff --git a/packages/qwik/src/core/preloader/bundle-graph.ts b/packages/qwik/src/core/preloader/bundle-graph.ts index 9a7f5f77eac..18da5e2850a 100644 --- a/packages/qwik/src/core/preloader/bundle-graph.ts +++ b/packages/qwik/src/core/preloader/bundle-graph.ts @@ -1,9 +1,11 @@ +import { isBrowser } from '@qwik.dev/core/build'; import { createMacroTask } from '../shared/platform/next-tick'; -import { config, isJSRegex, isBrowser, yieldInterval } from './constants'; -import { adjustProbabilities, bundles, shouldResetFactor, nextTriggerMacroTask } from './queue'; +import { config, isJSRegex, yieldInterval } from './constants'; +import { adjustProbabilities, bundles, nextTriggerMacroTask, shouldResetFactor } from './queue'; import type { BundleGraph, BundleImport, ImportProbability } from './types'; -import { BundleImportState_None, BundleImportState_Alias } from './types'; +import { BundleImportState_Alias, BundleImportState_None } from './types'; +// Note, making these singletons pulls in too much code and they are ok being module-scoped export let base: string | undefined; export let graph: BundleGraph; diff --git a/packages/qwik/src/core/preloader/queue.ts b/packages/qwik/src/core/preloader/queue.ts index 695ec84041a..419e8208509 100644 --- a/packages/qwik/src/core/preloader/queue.ts +++ b/packages/qwik/src/core/preloader/queue.ts @@ -1,3 +1,5 @@ +import type { QwikSymbolEvent } from '../shared/jsx/types/jsx-qwik-events'; +import { createMacroTask } from '../shared/platform/next-tick'; import { base, getBundle } from './bundle-graph'; import { config, doc, isBrowser, rel, yieldInterval } from './constants'; import type { BundleImport, BundleImports, ImportProbability } from './types'; @@ -7,8 +9,6 @@ import { BundleImportState_Preload, BundleImportState_Queued, } from './types'; -import type { QwikSymbolEvent } from '../shared/jsx/types/jsx-qwik-events'; -import { createMacroTask } from '../shared/platform/next-tick'; export const bundles: BundleImports = new Map(); export let shouldResetFactor: boolean; diff --git a/packages/qwik/src/core/preloader/queue.unit.ts b/packages/qwik/src/core/preloader/queue.unit.ts index f5a16f5d3db..43b1f1f9b81 100644 --- a/packages/qwik/src/core/preloader/queue.unit.ts +++ b/packages/qwik/src/core/preloader/queue.unit.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { createDocument } from '@qwik.dev/core/testing'; +const importAndResetState = async () => { + const { initPreloader } = await import('./bundle-graph'); + const { preload } = await import('./queue'); + return { initPreloader, preload }; +}; + const originalWindow = globalThis.window; const originalDocument = globalThis.document; const originalHTMLElement = globalThis.HTMLElement; @@ -72,8 +78,7 @@ test('appends preloads directly to head within a trigger slice', async () => { await installTestPlatform(); const headAppend = vi.spyOn(document.head, 'appendChild'); - const { initPreloader } = await import('./bundle-graph'); - const { preload } = await import('./queue'); + const { initPreloader, preload } = await importAndResetState(); initPreloader(['entry-a.js', 'entry-b.js']); preload(['entry-a.js', 'entry-b.js'], 1); @@ -103,8 +108,7 @@ test('yields after the frame budget and resumes later', async () => { const headAppend = vi.spyOn(document.head, 'appendChild'); const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - const { initPreloader } = await import('./bundle-graph'); - const { preload } = await import('./queue'); + const { initPreloader, preload } = await importAndResetState(); initPreloader(['entry-a.js', 'entry-b.js', 'entry-c.js']); preload(['entry-a.js', 'entry-b.js', 'entry-c.js'], 1); @@ -155,8 +159,7 @@ test('yields during dependency propagation and resumes later', async () => { const headAppend = vi.spyOn(document.head, 'appendChild'); const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - const { initPreloader } = await import('./bundle-graph'); - const { preload } = await import('./queue'); + const { initPreloader, preload } = await importAndResetState(); initPreloader(createLinearGraph(4)); preload('entry-a.js', 1); @@ -196,8 +199,7 @@ test('can yield more than once while propagating dependencies', async () => { const headAppend = vi.spyOn(document.head, 'appendChild'); const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - const { initPreloader } = await import('./bundle-graph'); - const { preload } = await import('./queue'); + const { initPreloader, preload } = await importAndResetState(); initPreloader(createLinearGraph(7)); preload('entry-a.js', 1); diff --git a/packages/qwik/src/core/shared/platform/platform.ts b/packages/qwik/src/core/shared/platform/platform.ts index 60bdfa16ce8..c7532d5192e 100644 --- a/packages/qwik/src/core/shared/platform/platform.ts +++ b/packages/qwik/src/core/shared/platform/platform.ts @@ -2,17 +2,23 @@ import { isServer } from '@qwik.dev/core/build'; import { QError, qError } from '../error/error'; import { getSymbolHash } from '../qrl/qrl-utils'; +import { registerSingleton } from '../singletons'; import { QBaseAttr } from '../utils/markers'; import { qDynamicPlatform } from '../utils/qdev'; import type { CorePlatform } from './types'; +// This is see also qrl.ts - importing from there causes loop +const symbolRegistry = isServer + ? registerSingleton('regSymbols', () => new Map()) + : undefined; + export const createPlatform = (): CorePlatform => { return { isServer, importSymbol(containerEl, url, symbolName) { if (isServer) { const hash = getSymbolHash(symbolName); - const regSym = (globalThis as any).__qwik_reg_symbols?.get(hash); + const regSym = symbolRegistry!.get(hash); if (regSym) { return regSym; } diff --git a/packages/qwik/src/core/shared/platform/platform.unit.ts b/packages/qwik/src/core/shared/platform/platform.unit.ts index 00f015b97bc..ada87df93e5 100644 --- a/packages/qwik/src/core/shared/platform/platform.unit.ts +++ b/packages/qwik/src/core/shared/platform/platform.unit.ts @@ -1,16 +1,19 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { createPlatform, setPlatform } from './platform'; import { getSymbolHash } from '../qrl/qrl-utils'; +import { getSingleton } from '../singletons'; + +const symbolRegistry = getSingleton>('regSymbols'); describe('core platform', () => { beforeEach(() => { // Initialize a fresh Map for each test to avoid pollution - (globalThis as any).__qwik_reg_symbols = new Map(); + symbolRegistry?.clear(); }); afterEach(() => { // Clean up global state - delete (globalThis as any).__qwik_reg_symbols; + symbolRegistry?.clear(); }); describe('importSymbol - server mode', () => { @@ -21,7 +24,7 @@ describe('core platform', () => { const symbolName = 'myComponent_abc123'; const hash = getSymbolHash(symbolName); const mockFunction = () => 'mock component'; - (globalThis as any).__qwik_reg_symbols.set(hash, mockFunction); + symbolRegistry?.set(hash, mockFunction); // importSymbol should return the registered symbol synchronously const result = await platform.importSymbol(null as any, '', symbolName); @@ -76,7 +79,7 @@ describe('core platform', () => { const symbolName = 'my_component_with_underscores_abc123'; const hash = getSymbolHash(symbolName); const mockFunction = () => 'mock'; - (globalThis as any).__qwik_reg_symbols.set(hash, mockFunction); + symbolRegistry?.set(hash, mockFunction); const result = await platform.importSymbol(null as any, '', symbolName); diff --git a/packages/qwik/src/core/shared/qrl/qrl-class.ts b/packages/qwik/src/core/shared/qrl/qrl-class.ts index a5cc32e5ba5..c9f08a7831c 100644 --- a/packages/qwik/src/core/shared/qrl/qrl-class.ts +++ b/packages/qwik/src/core/shared/qrl/qrl-class.ts @@ -17,7 +17,7 @@ import type { QRL, QrlArgs, QrlReturn } from './qrl.public'; // @ts-expect-error we don't have types for the preloader import { p as preload } from '@qwik.dev/core/preloader'; import { DomContainer } from '../../client/dom-container'; -import { loading } from '../serdes/inflate'; +import { loadingHolder } from '../serdes/inflate'; import type { Container } from '../types'; import { ElementVNode } from '../vnode/element-vnode'; @@ -438,11 +438,11 @@ const ensureQrlCaptures = (qrl: QRLClass) => { if (!container) { throw qError(QError.qrlMissingContainer); } - const prevLoading = loading; + const prevLoading = loadingHolder.p; _captures = qrl.$captures$ = deserializeCaptures(container, _captures); - if (loading !== prevLoading) { + if (loadingHolder.p !== prevLoading) { // return the loading promise so callers can await it - return loading; + return loadingHolder.p; } } }; diff --git a/packages/qwik/src/core/shared/qrl/qrl.ts b/packages/qwik/src/core/shared/qrl/qrl.ts index 23ed9b36100..4d5bccaf4c2 100644 --- a/packages/qwik/src/core/shared/qrl/qrl.ts +++ b/packages/qwik/src/core/shared/qrl/qrl.ts @@ -1,4 +1,6 @@ +import { isServer } from '@qwik.dev/core/build'; import { QError, qError } from '../error/error'; +import { registerSingleton } from '../singletons'; import { qDev } from '../utils/qdev'; import { isFunction, isString } from '../utils/types'; import { createQRL, type QRLInternal } from './qrl-class'; @@ -131,6 +133,10 @@ export const inlinedQrlDEV = ( return qrl; }; +// See also ../plaform/platform.ts +const symbolRegistry = isServer + ? registerSingleton('regSymbols', () => new Map()) + : undefined; /** * Register a QRL symbol globally for lookup by its hash. This is used by the optimizer to register * the names passed in `reg_ctx_name`. @@ -138,9 +144,6 @@ export const inlinedQrlDEV = ( * @internal */ export const _regSymbol = (symbol: any, hash: string) => { - if (typeof (globalThis as any).__qwik_reg_symbols === 'undefined') { - (globalThis as any).__qwik_reg_symbols = new Map(); - } - (globalThis as any).__qwik_reg_symbols.set(hash, symbol); + symbolRegistry!.set(hash, symbol); return symbol; }; diff --git a/packages/qwik/src/core/shared/serdes/allocate.ts b/packages/qwik/src/core/shared/serdes/allocate.ts index a4dcd2f01d1..f3ae5c2e686 100644 --- a/packages/qwik/src/core/shared/serdes/allocate.ts +++ b/packages/qwik/src/core/shared/serdes/allocate.ts @@ -19,6 +19,7 @@ import { qError, QError } from '../error/error'; import { JSXNodeImpl } from '../jsx/jsx-node'; import { createPropsProxy } from '../jsx/props-proxy'; import type { QRLInternal } from '../qrl/qrl-class'; +import { registerSingleton } from '../singletons'; import type { DeserializeContainer } from '../types'; import { _UNINITIALIZED } from '../utils/constants'; import type { ElementVNode } from '../vnode/element-vnode'; @@ -27,8 +28,14 @@ import { _constants, TypeIds, type Constants } from './constants'; import { needsInflation } from './deser-proxy'; import { createQRLWithBackChannel } from './qrl-to-string'; -export const resolvers = new WeakMap, [Function, Function]>(); -export const pendingStoreTargets = new Map(); +export const resolvers = registerSingleton( + 'resolvers', + () => new WeakMap, [Function, Function]>() +); +export const pendingStoreTargets = registerSingleton( + 'pendingStoreTargets', + () => new Map() +); export const allocate = (container: DeserializeContainer, typeId: number, value: unknown): any => { switch (typeId) { diff --git a/packages/qwik/src/core/shared/serdes/deser-proxy.ts b/packages/qwik/src/core/shared/serdes/deser-proxy.ts index 7f8081d3bd1..ef11d2b93cf 100644 --- a/packages/qwik/src/core/shared/serdes/deser-proxy.ts +++ b/packages/qwik/src/core/shared/serdes/deser-proxy.ts @@ -2,13 +2,17 @@ import { TypeIds } from './constants'; import type { DomContainer } from '../../client/dom-container'; import { vnode_isVNode } from '../../client/vnode-utils'; import { isObject } from '../utils/types'; +import { registerSingleton } from '../singletons'; import { allocate } from './allocate'; import { inflate } from './inflate'; /** Arrays/Objects are special-cased so their identifiers is a single digit. */ export const needsInflation = (typeId: TypeIds) => typeId >= TypeIds.Error || typeId === TypeIds.Array || typeId === TypeIds.Object; -const deserializedProxyMap = new WeakMap(); +const deserializedProxyMap = registerSingleton( + 'deserializedProxyMap', + () => new WeakMap() +); type DeserializerProxy = T & { [SERIALIZER_PROXY_UNWRAP]: object }; export const isDeserializerProxy = (value: unknown): value is DeserializerProxy => { diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index 277c14d5293..fa7251160f1 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -39,11 +39,17 @@ import type { DeserializeContainer, HostElement } from '../types'; import { _OWNER, _PROPS_HANDLER, _UNINITIALIZED } from '../utils/constants'; import { isString } from '../utils/types'; import type { VirtualVNode } from '../vnode/virtual-vnode'; +import { registerSingleton } from '../singletons'; import { allocate, pendingStoreTargets, resolvers } from './allocate'; import { TypeIds } from './constants'; import { needsInflation } from './deser-proxy'; -export let loading = Promise.resolve(); +interface LoadingHolder { + p: Promise; +} +export const loadingHolder = registerSingleton('loadingHolder', () => ({ + p: Promise.resolve(), +})); const dangerousObjectKeys = new Set([ 'constructor', @@ -225,7 +231,7 @@ export const inflate = ( const p = computed.$computeQrl$.resolve(container as any).catch(() => { // ignore preload errors }); - loading = loading.finally(() => p); + loadingHolder.p = loadingHolder.p.finally(() => p); if (d[1]) { computed.$effects$ = new Set(d[1]); } diff --git a/packages/qwik/src/core/shared/singletons.ts b/packages/qwik/src/core/shared/singletons.ts new file mode 100644 index 00000000000..c591a02dc23 --- /dev/null +++ b/packages/qwik/src/core/shared/singletons.ts @@ -0,0 +1,46 @@ +import { isServer } from '@qwik.dev/core/build'; +import { qError, QError } from './error/error'; +import { version } from '../version'; + +type Singletons = Record; +type QwikGlobal = { version?: string | undefined } & { [version: string]: Singletons }; + +const QWIK = ((globalThis as any).__qwik__ ||= {}) as QwikGlobal; + +// This will probably never happen, but better be safe +if (import.meta.hot) { + import.meta.hot.dispose(() => { + (globalThis as any).__qwik__ = undefined; + }); +} + +let singletons: Singletons; + +if (isServer) { + // We can only have 1 Qwik version + const existing = QWIK.version; + if (existing) { + if (existing !== version) { + // Server allows only one Qwik version per process; same-version coexistence is fine + // and gets to share the singleton state. + qError(QError.duplicateQwik, [existing, version]); + } + } else { + QWIK.version = version; + } + singletons = QWIK.singletons ||= {}; +} else { + // On the client, we can have multiple Qwik versions coexisting, but they don't share state. + singletons = QWIK[version] ||= {}; +} + +export const registerSingleton = (key: string, factory: () => T): T => { + if (!(key in singletons)) { + singletons[key] = factory(); + } + return singletons[key] as T; +}; + +export const getSingleton = (key: string): T | undefined => { + return singletons[key] as T | undefined; +}; diff --git a/packages/qwik/src/core/use/use-locale.ts b/packages/qwik/src/core/use/use-locale.ts index 8f019259862..38ba8b93e5d 100644 --- a/packages/qwik/src/core/use/use-locale.ts +++ b/packages/qwik/src/core/use/use-locale.ts @@ -2,15 +2,24 @@ import { tryGetInvokeContext } from './use-core'; import { getAsyncLocalStorage } from '../shared/platform/async-local-storage'; import { isServer } from '@qwik.dev/core/build'; import type { AsyncLocalStorage } from 'node:async_hooks'; +import { registerSingleton } from '../shared/singletons'; -let _locale: string | undefined = undefined; +interface LocaleStore { + locale: string | undefined; + asyncStore: AsyncLocalStorage | undefined; +} -let localAsyncStore: AsyncLocalStorage | undefined; +const localeStore = registerSingleton('localeStore', () => ({ + locale: undefined, + asyncStore: undefined, +})); if (isServer) { const AsyncLocalStorage = getAsyncLocalStorage(); if (AsyncLocalStorage) { - localAsyncStore = new AsyncLocalStorage(); + if (!localeStore.asyncStore) { + localeStore.asyncStore = new AsyncLocalStorage(); + } } } @@ -24,14 +33,14 @@ if (isServer) { */ export function getLocale(defaultLocale?: string): string { // Prefer per-request locale from local AsyncLocalStorage if available (server-side) - if (localAsyncStore) { - const locale = localAsyncStore.getStore(); + if (localeStore.asyncStore) { + const locale = localeStore.asyncStore.getStore(); if (locale) { return locale; } } - if (_locale === undefined) { + if (localeStore.locale === undefined) { const ctx = tryGetInvokeContext(); if (ctx && ctx.$locale$) { return ctx.$locale$; @@ -41,7 +50,7 @@ export function getLocale(defaultLocale?: string): string { } throw new Error('Reading `locale` outside of context.'); } - return _locale; + return localeStore.locale; } /** @@ -50,16 +59,16 @@ export function getLocale(defaultLocale?: string): string { * @public */ export function withLocale(locale: string, fn: () => T): T { - if (localAsyncStore) { - return localAsyncStore.run(locale, fn); + if (localeStore.asyncStore) { + return localeStore.asyncStore.run(locale, fn); } - const previousLang = _locale; + const previousLang = localeStore.locale; try { - _locale = locale; + localeStore.locale = locale; return fn(); } finally { - _locale = previousLang; + localeStore.locale = previousLang; } } @@ -72,9 +81,9 @@ export function withLocale(locale: string, fn: () => T): T { * @public */ export function setLocale(locale: string): void { - if (localAsyncStore) { - localAsyncStore.enterWith(locale); + if (localeStore.asyncStore) { + localeStore.asyncStore.enterWith(locale); return; } - _locale = locale; + localeStore.locale = locale; } diff --git a/packages/qwik/src/server/platform.ts b/packages/qwik/src/server/platform.ts index 76c8b2c0291..a542f76de44 100644 --- a/packages/qwik/src/server/platform.ts +++ b/packages/qwik/src/server/platform.ts @@ -1,7 +1,7 @@ import { setPlatform } from '@qwik.dev/core'; import { isDev } from '@qwik.dev/core/build'; import type { ResolvedManifest, SymbolMapperFn } from '@qwik.dev/core/optimizer'; -import { QError, qError, SYNC_QRL } from './qwik-copy'; +import { getSingleton, QError, qError, SYNC_QRL } from './qwik-copy'; import type { CorePlatformServer, SymbolMapper } from './qwik-types'; import type { SerializeDocumentOptions } from './types'; @@ -55,8 +55,8 @@ export function createPlatform( if (hash === SYNC_QRL) { return [hash, ''] as const; } - const isRegistered = (globalThis as any).__qwik_reg_symbols?.has(hash); - if (isRegistered) { + const regSymbols = getSingleton>('regSymbols'); + if (regSymbols?.has(hash)) { return [symbolName, '_'] as const; } console.error('Cannot resolve symbol', symbolName, 'in', mapper, parent); @@ -69,7 +69,7 @@ export function createPlatform( isServer: true, async importSymbol(_containerEl, url, symbolName) { const hash = getSymbolHash(symbolName); - const regSym = (globalThis as any).__qwik_reg_symbols?.get(hash); + const regSym = getSingleton>('regSymbols')?.get(hash); if (regSym) { return regSym; } diff --git a/packages/qwik/src/server/platform.unit.ts b/packages/qwik/src/server/platform.unit.ts index 826cd8cf228..de88f23d96f 100644 --- a/packages/qwik/src/server/platform.unit.ts +++ b/packages/qwik/src/server/platform.unit.ts @@ -1,15 +1,17 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { createPlatform, getSymbolHash } from './platform'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { _regSymbol } from '../core/shared/qrl/qrl'; describe('server platform', () => { beforeEach(() => { - // Initialize a fresh Map for each test to avoid pollution - (globalThis as any).__qwik_reg_symbols = new Map(); + // Initialize a fresh global Qwik state for each test to avoid pollution + (globalThis as any).__qwik = undefined; }); afterEach(() => { // Clean up global state - delete (globalThis as any).__qwik_reg_symbols; + (globalThis as any).__qwik = undefined; }); describe('importSymbol', () => { @@ -20,7 +22,7 @@ describe('server platform', () => { const symbolName = 'myComponent_abc123'; const hash = getSymbolHash(symbolName); const mockFunction = () => 'mock component'; - (globalThis as any).__qwik_reg_symbols.set(hash, mockFunction); + _regSymbol(mockFunction, hash); // importSymbol should return the registered symbol synchronously const result = await platform.importSymbol(null as any, '', symbolName); @@ -80,7 +82,7 @@ describe('server platform', () => { const symbolName = 'my_component_with_underscores_abc123'; const hash = getSymbolHash(symbolName); const mockFunction = () => 'mock'; - (globalThis as any).__qwik_reg_symbols.set(hash, mockFunction); + _regSymbol(mockFunction, hash); const result = await platform.importSymbol(null as any, '', symbolName); diff --git a/packages/qwik/src/server/qwik-copy.ts b/packages/qwik/src/server/qwik-copy.ts index 9321dd64c14..b5934d75aae 100644 --- a/packages/qwik/src/server/qwik-copy.ts +++ b/packages/qwik/src/server/qwik-copy.ts @@ -71,6 +71,7 @@ export { ChoreBits } from '../core/shared/vnode/enums/chore-bits.enum'; export { isHtmlAttributeAnEventName, isPreventDefault } from '../core/shared/utils/event-names'; export { ITERATION_ITEM_SINGLE, ITERATION_ITEM_MULTI } from '../core/shared/utils/markers'; export { isObjectEmpty } from '../core/shared/utils/objects'; +export { getSingleton } from '../core/shared/singletons'; export { LT, GT, diff --git a/packages/qwik/src/testing/platform.ts b/packages/qwik/src/testing/platform.ts index e6f877cf257..6f75909a795 100644 --- a/packages/qwik/src/testing/platform.ts +++ b/packages/qwik/src/testing/platform.ts @@ -2,6 +2,7 @@ import type { TestPlatform } from './types'; import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { getSymbolHash } from '../core/shared/qrl/qrl-utils'; +import { getSingleton } from '../core/shared/singletons'; function createPlatform() { const moduleCache = new Map(); @@ -9,7 +10,7 @@ function createPlatform() { isServer: false, importSymbol(containerEl, url, symbolName) { const hash = getSymbolHash(symbolName); - const regSym = (globalThis as any).__qwik_reg_symbols?.get(hash); + const regSym = getSingleton>('regSymbols')?.get(hash); if (regSym) { return regSym; } diff --git a/scripts/submodule-optimizer.ts b/scripts/submodule-optimizer.ts index 6130a7e8e3a..52fcb0419ba 100644 --- a/scripts/submodule-optimizer.ts +++ b/scripts/submodule-optimizer.ts @@ -61,7 +61,12 @@ export async function submoduleOptimizer(config: BuildConfig) { enforce: 'pre', resolveId(id) { // throws an error if files from src/core are loaded, except for some allowed imports - if (/src[/\\]core[\\/]/.test(id) && !id.includes('util') && !id.includes('shared')) { + if ( + /src[/\\]core[\\/]/.test(id) && + !id.includes('util') && + !id.includes('shared') && + !id.includes('version') + ) { console.error('forbid-core', id); throw new Error('Import of core files is not allowed in server builds.'); } diff --git a/scripts/submodule-server.ts b/scripts/submodule-server.ts index b900e2b2850..f7f993e87ba 100644 --- a/scripts/submodule-server.ts +++ b/scripts/submodule-server.ts @@ -66,6 +66,7 @@ export async function submoduleServer(config: BuildConfig, nameCache?: object) { args.path.includes('util') || args.path.includes('shared') || args.path.includes('ssr') || + args.path.includes('version') || // we allow building preloader into server builds args.path.includes('preloader') ) { From c0af15120e54e64d8dab627bdeccbf38482c79a6 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Wed, 20 May 2026 16:34:27 +0200 Subject: [PATCH 2/2] fix(core): singleton for QRL captures --- .changeset/qrl-captures-singleton.md | 5 ++ e2e/adapters-e2e/package.json | 4 +- package.json | 4 +- packages/docs/package.json | 2 +- packages/docs/src/routes/api/qwik/api.json | 4 +- packages/docs/src/routes/api/qwik/index.mdx | 8 ++++ packages/optimizer/core/README.md | 12 ++--- packages/optimizer/core/src/code_move.rs | 47 +++++++++++-------- .../optimizer/core/src/props_destructuring.rs | 17 +++++-- ..._component_level_self_referential_qrl.snap | 10 ++-- ...nent_with_event_listeners_inside_loop.snap | 24 +++++----- ...est__example_custom_inlined_functions.snap | 8 ++-- ..._test__example_functional_component_2.snap | 4 +- ...le_functional_component_capture_props.snap | 4 +- ...ore__test__example_immutable_analysis.snap | 8 ++-- ..._test__example_inlined_entry_strategy.snap | 4 +- .../qwik_core__test__example_jsx.snap | 4 +- .../qwik_core__test__example_lib_mode.snap | 8 ++-- ..._test__example_lightweight_functional.snap | 8 ++-- ...wik_core__test__example_manual_chunks.snap | 8 ++-- ...wik_core__test__example_multi_capture.snap | 8 ++-- ...test__example_optimization_issue_3542.snap | 4 +- ...ore__test__example_props_optimization.snap | 8 ++-- ...ore__test__example_qwik_router_client.snap | 44 ++++++++--------- ...k_core__test__example_renamed_exports.snap | 4 +- ...core__test__example_strip_client_code.snap | 4 +- ...core__test__example_strip_server_code.snap | 4 +- ...core__test__example_use_client_effect.snap | 4 +- ..._core__test__example_use_server_mount.snap | 8 ++-- .../qwik_core__test__fun_with_scopes.snap | 6 +-- .../snapshots/qwik_core__test__issue_150.snap | 4 +- ...core__test__should_convert_rest_props.snap | 4 +- ...s_with_item_and_index_and_capture_ref.snap | 10 ++-- ...core__test__should_extract_single_qrl.snap | 10 ++-- ...re__test__should_extract_single_qrl_2.snap | 6 +-- ..._should_extract_single_qrl_with_index.snap | 10 ++-- ...mark_props_as_var_props_for_inner_cmp.snap | 4 +- ...enerate_conflicting_props_identifiers.snap | 6 +-- ..._preserve_non_ident_explicit_captures.snap | 28 +++++------ ...capturing_cross_scope_in_nested_loops.snap | 10 ++-- ...__test__should_transform_nested_loops.snap | 6 +-- ..._transform_qrls_in_ternary_expression.snap | 6 +-- ...ted_loops_handler_captures_outer_only.snap | 6 +-- ...uld_wrap_prop_from_destructured_array.snap | 4 +- .../qwik_core__test__ternary_prop.snap | 4 +- packages/optimizer/core/src/test.rs | 32 ++++++------- packages/optimizer/core/src/transform.rs | 26 +++++++--- packages/optimizer/core/src/words.rs | 2 +- packages/qwik/src/core/client/run-qrl.ts | 4 +- packages/qwik/src/core/client/run-qrl.unit.ts | 22 ++++----- packages/qwik/src/core/control-flow/each.ts | 4 +- .../qwik/src/core/control-flow/reveal.tsx | 6 +-- .../qwik/src/core/control-flow/suspense.tsx | 10 ++-- packages/qwik/src/core/index.ts | 1 + packages/qwik/src/core/internal.ts | 2 +- packages/qwik/src/core/qwik.core.api.md | 6 ++- packages/qwik/src/core/readme.md | 2 +- .../qwik/src/core/shared/jsx/bind-handlers.ts | 13 ++--- .../qwik/src/core/shared/qrl/qrl-class.ts | 31 ++++++++---- packages/qwik/src/core/shared/qrl/qrl.unit.ts | 5 +- .../qwik/src/core/tests/use-computed.spec.tsx | 4 +- packages/qwik/src/core/use/use-hmr.ts | 6 +-- .../src/core/use/use-lexical-scope.public.ts | 9 ++-- packages/qwik/src/core/use/use-resource.ts | 4 +- packages/qwik/src/core/use/use-task-dollar.ts | 6 +++ packages/qwik/src/core/use/use-task.ts | 4 +- .../src/core/use/use-visible-task-dollar.ts | 8 ++++ 67 files changed, 341 insertions(+), 271 deletions(-) create mode 100644 .changeset/qrl-captures-singleton.md diff --git a/.changeset/qrl-captures-singleton.md b/.changeset/qrl-captures-singleton.md new file mode 100644 index 00000000000..14ca3ddaef3 --- /dev/null +++ b/.changeset/qrl-captures-singleton.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +BREAKING (only when using internal v2 beta API): The `_captures` variable is now a singleton object called `_capturesObj` with a single property `_` that contains the captures string. Normally this should not impact you. diff --git a/e2e/adapters-e2e/package.json b/e2e/adapters-e2e/package.json index a385fdb259c..1010e4cb981 100644 --- a/e2e/adapters-e2e/package.json +++ b/e2e/adapters-e2e/package.json @@ -17,11 +17,11 @@ "build.server.deno": "vite build -c adapters/deno/vite.config.ts", "build.server.express": "vite build -c adapters/express/vite.config.ts", "build.types": "tsc --incremental --noEmit", + "bun": "pnpm build.runtime.bun && pnpm serve.bun", + "deno": "pnpm build.runtime.deno && pnpm serve.deno", "deploy": "vercel deploy", "dev": "vite --mode ssr", "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force", - "bun": "pnpm build.runtime.bun && pnpm serve.bun", - "deno": "pnpm build.runtime.deno && pnpm serve.deno", "express": "pnpm build.runtime.express && pnpm serve.express", "fmt": "prettier --write .", "fmt.check": "prettier --check .", diff --git a/package.json b/package.json index 3622142cbe0..d26b3e5e111 100644 --- a/package.json +++ b/package.json @@ -234,9 +234,9 @@ "lint.prettier": "prettier --cache --check .", "lint.rust": "make lint", "lint.syncpack": "syncpack list-mismatches", + "pack.core": "node --require ./scripts/runBefore.ts scripts/pack-local-qwik-core.ts", "preinstall": "npx only-allow pnpm", "prepare": "simple-git-hooks", - "pack.core": "node --require ./scripts/runBefore.ts scripts/pack-local-qwik-core.ts", "prettier.fix": "prettier --cache --write .", "qwik-push-build-repos": "node --require ./scripts/runBefore.ts ./scripts/qwik-push-build-repos.ts", "release": "changeset publish", @@ -264,9 +264,9 @@ "test.e2e.firefox": "playwright test e2e/qwik-e2e/tests --browser=firefox --config e2e/qwik-e2e/playwright.config.ts", "test.e2e.integrations.bun.chromium": "playwright test e2e/adapters-e2e/tests --project=chromium --config e2e/adapters-e2e/playwright.bun.config.ts", "test.e2e.integrations.bun.webkit": "playwright test e2e/adapters-e2e/tests --project=webkit --config e2e/adapters-e2e/playwright.bun.config.ts", + "test.e2e.integrations.chromium": "playwright test e2e/adapters-e2e/tests --project=chromium --config e2e/adapters-e2e/playwright.config.ts", "test.e2e.integrations.deno.chromium": "playwright test e2e/adapters-e2e/tests --project=chromium --config e2e/adapters-e2e/playwright.deno.config.ts", "test.e2e.integrations.deno.webkit": "playwright test e2e/adapters-e2e/tests --project=webkit --config e2e/adapters-e2e/playwright.deno.config.ts", - "test.e2e.integrations.chromium": "playwright test e2e/adapters-e2e/tests --project=chromium --config e2e/adapters-e2e/playwright.config.ts", "test.e2e.integrations.webkit": "playwright test e2e/adapters-e2e/tests --project=webkit --config e2e/adapters-e2e/playwright.config.ts", "test.e2e.qwik-react.chromium": "playwright test e2e/qwik-react-e2e/tests --project=chromium --config e2e/qwik-react-e2e/playwright.config.ts", "test.e2e.qwik-react.webkit": "playwright test e2e/qwik-react-e2e/tests --project=webkit --config e2e/qwik-react-e2e/playwright.config.ts", diff --git a/packages/docs/package.json b/packages/docs/package.json index 3b6175fca40..1ff97f4b4dd 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -41,8 +41,8 @@ "gray-matter": "4.0.3", "leaflet": "1.9.4", "magic-string": "0.30.21", - "playwright": "1.57.0", "pagefind": "1.4.0", + "playwright": "1.57.0", "prettier": "3.7.4", "prism-themes": "1.9.0", "prismjs": "1.30.0", diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 99abf3ef791..3f8dbc7710e 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -2786,7 +2786,7 @@ } ], "kind": "Function", - "content": "Reruns the `taskFn` when the observed inputs change.\n\nUse `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those inputs change.\n\nThe `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun.\n\n\n```typescript\nuseTask$: (fn: TaskFn, opts?: TaskOptions) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfn\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\n[TaskOptions](#taskoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nvoid", + "content": "Reruns the `taskFn` when the observed inputs change.\n\nUse `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those inputs change.\n\nThe `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun.\n\nCleanup callbacks registered with `cleanup()` or returned from the task may be async. When a task reruns, Qwik waits for the previous cleanup to finish before starting the next invocation.\n\nDuring SSR, the cleanup function is called immediately after SSR completes. Therefore, it is not called on the client side after resuming, but only the second time the task runs on the client.\n\n\n```typescript\nuseTask$: (fn: TaskFn, opts?: TaskOptions) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfn\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\n[TaskOptions](#taskoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nvoid", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts", "mdFile": "core.usetask_.md" }, @@ -2800,7 +2800,7 @@ } ], "kind": "Function", - "content": "```tsx\nconst Timer = component$(() => {\n const store = useStore({\n count: 0,\n });\n\n useVisibleTask$(() => {\n // Only runs in the client\n const timer = setInterval(() => {\n store.count++;\n }, 500);\n return () => {\n clearInterval(timer);\n };\n });\n\n return
{store.count}
;\n});\n```\n\n\n```typescript\nuseVisibleTask$: (fn: TaskFn, opts?: OnVisibleTaskOptions) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfn\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\n[OnVisibleTaskOptions](#onvisibletaskoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nvoid", + "content": "```tsx\nconst Timer = component$(() => {\n const store = useStore({\n count: 0,\n });\n\n useVisibleTask$(() => {\n // Only runs in the client\n const timer = setInterval(() => {\n store.count++;\n }, 500);\n return () => {\n clearInterval(timer);\n };\n });\n\n return
{store.count}
;\n});\n```\nVisible Tasks are a variant of Tasks that only run in the browser, and are registered but not executed during SSR. They are useful for running code that should only execute in the browser, such as code that interacts with the DOM or browser APIs.\n\nCleanup callbacks registered with `cleanup()` or returned from the task may be async. When a visible task reruns, Qwik waits for the previous cleanup to finish before starting the next invocation.\n\n\n```typescript\nuseVisibleTask$: (fn: TaskFn, opts?: OnVisibleTaskOptions) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfn\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\n[OnVisibleTaskOptions](#onvisibletaskoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nvoid", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-visible-task-dollar.ts", "mdFile": "core.usevisibletask_.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index e60d9ff0c6e..fd921e91de3 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -10458,6 +10458,10 @@ Use `useTask` to observe changes on a set of inputs, and then re-execute the `ta The `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun. +Cleanup callbacks registered with `cleanup()` or returned from the task may be async. When a task reruns, Qwik waits for the previous cleanup to finish before starting the next invocation. + +During SSR, the cleanup function is called immediately after SSR completes. Therefore, it is not called on the client side after resuming, but only the second time the task runs on the client. + ```typescript useTask$: (fn: TaskFn, opts?: TaskOptions) => void ``` @@ -10529,6 +10533,10 @@ const Timer = component$(() => { }); ``` +Visible Tasks are a variant of Tasks that only run in the browser, and are registered but not executed during SSR. They are useful for running code that should only execute in the browser, such as code that interacts with the DOM or browser APIs. + +Cleanup callbacks registered with `cleanup()` or returned from the task may be async. When a visible task reruns, Qwik waits for the previous cleanup to finish before starting the next invocation. + ```typescript useVisibleTask$: (fn: TaskFn, opts?: OnVisibleTaskOptions) => void ``` diff --git a/packages/optimizer/core/README.md b/packages/optimizer/core/README.md index b71f5110123..8608cf1c91e 100644 --- a/packages/optimizer/core/README.md +++ b/packages/optimizer/core/README.md @@ -20,9 +20,9 @@ useTask$(() => { useTaskQrl(qrl(() => import('./myFile_useTask_abc123'), 's_abc123', [state])); // Output (segment module: myFile_useTask_abc123.js) -import { _captures } from '@qwik.dev/core'; +import { _capturesObj } from '@qwik.dev/core'; export const s_abc123 = () => { - const state = _captures[0]; + const state = _capturesObj._[0]; console.log(state.count); }; ``` @@ -32,7 +32,7 @@ export const s_abc123 = () => { A segment is an extracted closure with metadata. Each segment becomes a separate ES module file (in non-inline strategies). The segment module contains: 1. Imports for all externally-referenced identifiers -2. A `_captures` import if the closure captures lexical variables +2. A `_capturesObj` import if the closure captures lexical variables 3. The closure body as a named export ### Captures (Scoped Identifiers) @@ -41,7 +41,7 @@ When a `$`-closure references variables from its enclosing lexical scope (not im 1. Identifies captured variables by walking the closure body and checking each identifier against the lexical scope stack 2. Passes them as an array argument to `qrl()`: `qrl(import, "name", [var1, var2])` -3. In the segment module, rewrites the function to read captures from `_captures`: `const var1 = _captures[0]` +3. In the segment module, rewrites the function to read captures from `_capturesObj`: `const var1 = _capturesObj._[0]` **Exception — event handlers on native elements:** For `$`-props on native elements (e.g., `onClick$` on `