From 92681c7989e3b1cde4aaa1688fad43c5ef5ab608 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 17 Jun 2026 09:55:24 -0700 Subject: [PATCH 1/5] Add e2e test for data-driven dependencies (@module) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the minimal setup for Relay 3D with the current compiler: - No JSDependency scalar or js() field needed — the compiler generates componentModuleProvider/operationModuleProvider as inline dynamic imports - OperationLoader calls the operationModuleProvider to async-load split normalization ASTs - MatchContainer loader wraps componentModuleProvider with React.lazy for Suspense-based code splitting - Server schema is just a plain union, no special 3D infrastructure Also switches the e2e test infra to native ESM for TypeScript fixtures: - extensionsToTreatAsEsm: ['.ts', '.tsx'] in jest config - Skip CJS module transform for TS fixture files in jest-transform - Use import() instead of require() in the test harness - NODE_OPTIONS=--experimental-vm-modules in the test:e2e script - eagerEsModules: true for babel-plugin-relay This eliminates CJS/ESM interop issues where import() of Babel-transformed modules produced nested { default: { default: Component } }. --- package.json | 2 +- .../relay-e2e-test/__tests__/fixtures-test.js | 4 +- .../data-driven-dependencies/module.md | 205 ++++++++++++++++++ .../data-driven-dependencies/module.snap.md | 5 + packages/relay-e2e-test/jest-transform.js | 9 +- packages/relay-e2e-test/jest.config.js | 3 + 6 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md create mode 100644 packages/relay-e2e-test/fixtures/data-driven-dependencies/module.snap.md diff --git a/package.json b/package.json index 485b426f3dd6c..bb5c7ba647af9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "prettier": "find . -name node_modules -prune -or -name dist -prune -or -name '*.js' -print | xargs prettier --write", "prettier-check": "find . -name node_modules -prune -or -name dist -prune -or -name '*.js' -print | xargs prettier --check", "test": "f() { EXIT=0; npm run typecheck || EXIT=$?; npm run test-dependencies || EXIT=$?; npm run jest \"$@\" || EXIT=$?; exit $EXIT; }; f", - "test:e2e": "cross-env NODE_ENV=test jest --config packages/relay-e2e-test/jest.config.js", + "test:e2e": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --config packages/relay-e2e-test/jest.config.js", "test:e2e:install": "cd packages/relay-e2e-test && yarn install", "test-dependencies": "node ./scripts/testDependencies.js", "typecheck": "flow check" diff --git a/packages/relay-e2e-test/__tests__/fixtures-test.js b/packages/relay-e2e-test/__tests__/fixtures-test.js index 59054bf0792cd..0ab9791d8d738 100644 --- a/packages/relay-e2e-test/__tests__/fixtures-test.js +++ b/packages/relay-e2e-test/__tests__/fixtures-test.js @@ -68,10 +68,10 @@ for (const file of fixtureFiles) { try { await runFixture(tempDir); - // Dynamic require of the built App component + // Dynamic import of the built App component // Jest's transform will handle TSX compilation const appPath = path.join(tempDir, 'template', 'App.tsx'); - const appModule = require(appPath); + const appModule = await import(appPath); const TestApp = appModule.default; // Render with React Testing Library diff --git a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md new file mode 100644 index 0000000000000..c4e1aa68d8516 --- /dev/null +++ b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md @@ -0,0 +1,205 @@ +# Data-Driven Dependencies (@module) + +Demonstrates the minimal setup for Relay's data-driven dependencies (3D). +A query field returns a union type, and each concrete type uses `@module` to +associate a fragment with a dynamically loaded React component. At runtime, +`MatchContainer` renders whichever component the server selected. + +The compiler generates `componentModuleProvider` and `operationModuleProvider` +dynamic imports in the normalization AST, so the schema does not need +`JSDependency` or `js()` fields — the module references are resolved entirely +on the client. + +## Relay Config + +```json title="relay.config.json" +{ + "src": "./", + "schema": "./schema.graphql", + "language": "typescript" +} +``` + +## Server + +No special schema support is needed — just a union type with concrete members. + +```ts title="server.ts" +/** @gqlUnion */ +type NameRenderer = PlainTextRenderer | MarkdownRenderer; + +/** @gqlType */ +type PlainTextRenderer = { + __typename: "PlainTextRenderer"; + /** @gqlField */ + plaintext: string; +}; + +/** @gqlType */ +type MarkdownRenderer = { + __typename: "MarkdownRenderer"; + /** @gqlField */ + markdown: string; +}; + +/** @gqlType */ +type Viewer = { + /** @gqlField */ + nameRenderer: NameRenderer; +}; + +/** @gqlQueryField */ +export function viewer(): Viewer { + return { + nameRenderer: { + __typename: "MarkdownRenderer", + markdown: "**Hello, world!**", + }, + }; +} +``` + +## Markdown Renderer Component + +Each `@module` branch gets its own file with a fragment and a React component. +The `@module(name: ...)` value becomes the `import()` path in the generated +normalization AST, resolved relative to `__generated__/`. + +```tsx title="MarkdownRendererView.tsx" +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +export default function MarkdownRendererView(props: any) { + const data = useFragment( + graphql` + fragment MarkdownRendererView_name on MarkdownRenderer { + markdown + } + `, + props.name, + ); + return
Markdown: {data.markdown}
; +} +``` + +## Plain Text Renderer Component + +```tsx title="PlainTextRendererView.tsx" +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +export default function PlainTextRendererView(props: any) { + const data = useFragment( + graphql` + fragment PlainTextRendererView_name on PlainTextRenderer { + plaintext + } + `, + props.name, + ); + return
Plain: {data.plaintext}
; +} +``` + +## App + +The `@module(name: ...)` value is set to a relative path that resolves from +the source file to the component. The compiler embeds this as an `import()` +call in the normalization AST (resolved relative to `__generated__/`). + +`MatchContainer`'s `loader` receives the `componentModuleProvider` function +that the normalizer stored in the Relay record. We wrap it with `React.lazy` +for Suspense-based async loading — the same pattern a production app would use +for code splitting. + +```tsx title="App.tsx" +import React, { Suspense } from "react"; +import { + RelayEnvironmentProvider, + useLazyLoadQuery, +} from "react-relay"; +import { graphql, Environment, RecordSource, Store } from "relay-runtime"; +import MatchContainer from "react-relay/relay-hooks/MatchContainer"; +import { gratsNetwork } from "../GratsNetwork"; + +// OperationLoader: resolves the operationModuleProvider dynamic imports +// generated by the compiler for each @module branch's split normalization AST. +const operationLoader = { + get(_reference: unknown) { + return null; + }, + load(reference: unknown) { + if (typeof reference === "function") { + return (reference as () => Promise)().then( + (m: any) => m.default, + ); + } + return Promise.resolve(null); + }, +}; + +const store = new Store(new RecordSource(), { operationLoader }); + +const testEnvironment = new Environment({ + network: gratsNetwork, + operationLoader, + store, +}); + +// MatchContainer's loader receives the componentModuleProvider function +// (e.g. () => import('../MarkdownRendererView')) that the normalizer stored +// in the Relay record. Wrap it with React.lazy for Suspense-based loading. +const lazyCache = new Map>(); + +function moduleLoader(ref: unknown): React.ComponentType { + if (!lazyCache.has(ref)) { + lazyCache.set( + ref, + React.lazy(ref as () => Promise<{ default: React.ComponentType }>), + ); + } + return lazyCache.get(ref)!; +} + +function App() { + const data = useLazyLoadQuery( + graphql` + query AppQuery { + viewer { + nameRenderer { + ...MarkdownRendererView_name + @module(name: "./MarkdownRendererView") + ...PlainTextRendererView_name + @module(name: "./PlainTextRendererView") + } + } + } + `, + {}, + ); + + return ( + + ); +} + +export default function TestApp() { + return ( + + Loading...}> + + + + ); +} +``` + +## Steps + +```steps +wait "Markdown:" +``` diff --git a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.snap.md b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.snap.md new file mode 100644 index 0000000000000..7e7505ab5b22a --- /dev/null +++ b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.snap.md @@ -0,0 +1,5 @@ +## HTML + +```html +
Markdown: **Hello, world!**
+``` diff --git a/packages/relay-e2e-test/jest-transform.js b/packages/relay-e2e-test/jest-transform.js index 5972bc822ac80..3721c14025e09 100644 --- a/packages/relay-e2e-test/jest-transform.js +++ b/packages/relay-e2e-test/jest-transform.js @@ -74,7 +74,7 @@ module.exports = { plugins.push([reactJsx, {runtime: 'automatic'}]); // Relay babel transform for graphql`` tagged templates - plugins.push([findBabelPluginRelay(), {eagerEsModules: false}]); + plugins.push([findBabelPluginRelay(), {eagerEsModules: true}]); // Rewrite Haste-style bare module imports used by Relay source plugins.push([ @@ -87,8 +87,11 @@ module.exports = { }, ]); - // Convert ES modules to CommonJS for Jest - plugins.push(modulesCommonjs); + // Convert ES modules to CommonJS for Jest — but skip for TypeScript + // fixture files so import() returns clean ESM without nested defaults. + if (!isTypeScript || isRelaySource) { + plugins.push(modulesCommonjs); + } return babel.transformSync(src, { filename, diff --git a/packages/relay-e2e-test/jest.config.js b/packages/relay-e2e-test/jest.config.js index b3822a405dec2..70af129b7d61e 100644 --- a/packages/relay-e2e-test/jest.config.js +++ b/packages/relay-e2e-test/jest.config.js @@ -56,6 +56,9 @@ module.exports = { '/node_modules', path.join(mainRoot, 'node_modules'), ], + // Treat TypeScript files as ESM so dynamic import() returns clean modules + // without nested CJS default-wrapping. + extensionsToTreatAsEsm: ['.ts', '.tsx'], // Use jsdom for DOM testing with @testing-library testEnvironment: 'jest-environment-jsdom', roots: [''], From e8ccd72912d95fe136626302993f2aff26d8f9f8 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 17 Jun 2026 10:52:45 -0700 Subject: [PATCH 2/5] Default OperationLoader for @module dynamic imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environment now auto-creates an OperationLoader when none is provided. The default handles the () => import(...) function references that the compiler generates for componentModuleProvider/operationModuleProvider, with a cache so that get() returns results synchronously after the first async load() — needed by DataChecker and RelayReferenceMarker. Users no longer need to define a custom operationLoader or construct a Store explicitly to use @module. createOperationLoader is also exported from relay-runtime for users who construct their own Store. --- .../data-driven-dependencies/module.md | 30 +++--------- packages/relay-runtime/index.d.ts | 1 + packages/relay-runtime/index.js | 2 + .../store/RelayModernEnvironment.js | 16 +++--- .../util/createOperationLoader.d.ts | 10 ++++ .../util/createOperationLoader.js | 49 +++++++++++++++++++ 6 files changed, 77 insertions(+), 31 deletions(-) create mode 100644 packages/relay-runtime/util/createOperationLoader.d.ts create mode 100644 packages/relay-runtime/util/createOperationLoader.js diff --git a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md index c4e1aa68d8516..8569f3e74d3fc 100644 --- a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md +++ b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md @@ -107,6 +107,10 @@ The `@module(name: ...)` value is set to a relative path that resolves from the source file to the component. The compiler embeds this as an `import()` call in the normalization AST (resolved relative to `__generated__/`). +The `Environment` provides a default `OperationLoader` that handles +`() => import(...)` function references, so no explicit `operationLoader` +configuration is needed. + `MatchContainer`'s `loader` receives the `componentModuleProvider` function that the normalizer stored in the Relay record. We wrap it with `React.lazy` for Suspense-based async loading — the same pattern a production app would use @@ -118,33 +122,11 @@ import { RelayEnvironmentProvider, useLazyLoadQuery, } from "react-relay"; -import { graphql, Environment, RecordSource, Store } from "relay-runtime"; +import { graphql, Environment } from "relay-runtime"; import MatchContainer from "react-relay/relay-hooks/MatchContainer"; import { gratsNetwork } from "../GratsNetwork"; -// OperationLoader: resolves the operationModuleProvider dynamic imports -// generated by the compiler for each @module branch's split normalization AST. -const operationLoader = { - get(_reference: unknown) { - return null; - }, - load(reference: unknown) { - if (typeof reference === "function") { - return (reference as () => Promise)().then( - (m: any) => m.default, - ); - } - return Promise.resolve(null); - }, -}; - -const store = new Store(new RecordSource(), { operationLoader }); - -const testEnvironment = new Environment({ - network: gratsNetwork, - operationLoader, - store, -}); +const testEnvironment = new Environment({ network: gratsNetwork }); // MatchContainer's loader receives the componentModuleProvider function // (e.g. () => import('../MarkdownRendererView')) that the normalizer stored diff --git a/packages/relay-runtime/index.d.ts b/packages/relay-runtime/index.d.ts index 88910b2bded9c..0265e242ccdb9 100644 --- a/packages/relay-runtime/index.d.ts +++ b/packages/relay-runtime/index.d.ts @@ -213,6 +213,7 @@ export { fetchQuery_DEPRECATED } from './query/fetchQuery_DEPRECATED'; export { isRelayModernEnvironment } from './store/isRelayModernEnvironment'; export { requestSubscription } from './subscription/requestSubscription'; // Utilities +export { default as createOperationLoader } from './util/createOperationLoader'; export { default as createPayloadFor3DField } from './util/createPayloadFor3DField'; export { default as getFragmentIdentifier } from './util/getFragmentIdentifier'; export { default as getPaginationMetadata } from './util/getPaginationMetadata'; diff --git a/packages/relay-runtime/index.js b/packages/relay-runtime/index.js index 196eb3c4b144e..47e7a22411e2b 100644 --- a/packages/relay-runtime/index.js +++ b/packages/relay-runtime/index.js @@ -63,6 +63,7 @@ const RelayStoreUtils = require('./store/RelayStoreUtils'); const ResolverFragments = require('./store/ResolverFragments'); const ViewerPattern = require('./store/ViewerPattern'); const requestSubscription = require('./subscription/requestSubscription'); +const createOperationLoader = require('./util/createOperationLoader'); const createPayloadFor3DField = require('./util/createPayloadFor3DField'); const deepFreeze = require('./util/deepFreeze'); const getFragmentIdentifier = require('./util/getFragmentIdentifier'); @@ -422,6 +423,7 @@ module.exports = { // Utilities PreloadableQueryRegistry, RelayProfiler: RelayProfiler, + createOperationLoader: createOperationLoader, createPayloadFor3DField: createPayloadFor3DField, // INTERNAL-ONLY: These exports might be removed at any point. diff --git a/packages/relay-runtime/store/RelayModernEnvironment.js b/packages/relay-runtime/store/RelayModernEnvironment.js index 49a8f85729e5c..4ecc9359744a9 100644 --- a/packages/relay-runtime/store/RelayModernEnvironment.js +++ b/packages/relay-runtime/store/RelayModernEnvironment.js @@ -61,6 +61,7 @@ const OperationExecutor = require('./OperationExecutor'); const RelayModernStore = require('./RelayModernStore'); const RelayPublishQueue = require('./RelayPublishQueue'); const RelayRecordSource = require('./RelayRecordSource'); +const createOperationLoader = require('../util/createOperationLoader'); const invariant = require('invariant'); export type EnvironmentConfig = { @@ -109,16 +110,17 @@ class RelayModernEnvironment implements IEnvironment { this.configName = config.configName; this._treatMissingFieldsAsNull = config.treatMissingFieldsAsNull === true; this._deferDeduplicatedFields = config.deferDeduplicatedFields === true; - const operationLoader = config.operationLoader; + const operationLoader = + config.operationLoader ?? createOperationLoader(); if (__DEV__) { - if (operationLoader != null) { + if (config.operationLoader != null) { invariant( - typeof operationLoader === 'object' && - typeof operationLoader.get === 'function' && - typeof operationLoader.load === 'function', + typeof config.operationLoader === 'object' && + typeof config.operationLoader.get === 'function' && + typeof config.operationLoader.load === 'function', 'RelayModernEnvironment: Expected `operationLoader` to be an object ' + 'with get() and load() functions, got `%s`.', - operationLoader, + config.operationLoader, ); } } @@ -127,7 +129,7 @@ class RelayModernEnvironment implements IEnvironment { new RelayModernStore(new RelayRecordSource(), { getDataID: config.getDataID, log: config.log, - operationLoader: config.operationLoader, + operationLoader, shouldProcessClientComponents: config.shouldProcessClientComponents, }); diff --git a/packages/relay-runtime/util/createOperationLoader.d.ts b/packages/relay-runtime/util/createOperationLoader.d.ts new file mode 100644 index 0000000000000..252be4592342d --- /dev/null +++ b/packages/relay-runtime/util/createOperationLoader.d.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {OperationLoader} from '../store/RelayStoreTypes'; + +export default function createOperationLoader(): OperationLoader; diff --git a/packages/relay-runtime/util/createOperationLoader.js b/packages/relay-runtime/util/createOperationLoader.js new file mode 100644 index 0000000000000..b78deb041afa9 --- /dev/null +++ b/packages/relay-runtime/util/createOperationLoader.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall relay + */ + +'use strict'; + +import type {NormalizationRootNode} from '../util/NormalizationNode'; +import type {OperationLoader} from '../store/RelayStoreTypes'; + +/** + * Creates a default OperationLoader that resolves module references produced + * by the Relay compiler's `componentModuleProvider` / `operationModuleProvider` + * dynamic imports (e.g. `() => import('./Fragment$normalization.graphql')`). + * + * After the first async `load()`, the result is cached so that subsequent + * synchronous `get()` calls (used by DataChecker and RelayReferenceMarker) + * return the module immediately. + */ +function createOperationLoader(): OperationLoader { + const cache: Map = new Map(); + return { + get(reference: unknown): ?NormalizationRootNode { + return cache.get(reference) ?? null; + }, + load(reference: unknown): Promise { + if (typeof reference === 'function') { + const loader: () => Promise<{default?: mixed}> = (reference: $FlowFixMe); + return loader().then(mod => { + const node: NormalizationRootNode = + mod.default != null + ? (mod.default: $FlowFixMe) + : (mod: $FlowFixMe); + cache.set(reference, node); + return node; + }); + } + return Promise.resolve(null); + }, + }; +} + +module.exports = createOperationLoader; From d70507403ce7d8494e6e8a753330142463e18a05 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 17 Jun 2026 10:53:26 -0700 Subject: [PATCH 3/5] Prefetch component module at normalization time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When componentModuleProvider is a function (the () => import(...) generated by the compiler), call it eagerly during normalization to start loading the component module in parallel with the operation normalization AST. Module systems cache import() calls, so when MatchContainer's loader calls the same function at render time it resolves from the cache — eliminating the waterfall where the component only starts loading after render. --- packages/relay-runtime/store/RelayResponseNormalizer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/relay-runtime/store/RelayResponseNormalizer.js b/packages/relay-runtime/store/RelayResponseNormalizer.js index 24f8ff58f8828..c0aabf2c71bfe 100644 --- a/packages/relay-runtime/store/RelayResponseNormalizer.js +++ b/packages/relay-runtime/store/RelayResponseNormalizer.js @@ -532,6 +532,13 @@ class RelayResponseNormalizer { componentKey, componentReference ?? null, ); + // Prefetch the component module so it loads in parallel with the + // operation normalization AST. Module systems cache import() calls, + // so when MatchContainer's loader calls the same function at render + // time, it resolves from the cache without a waterfall. + if (typeof componentReference === 'function') { + (componentReference: any)().catch(() => {}); + } const operationKey = getModuleOperationKey(moduleImport.documentName); const operationReference = moduleImport.operationModuleProvider || data[operationKey]; From c07dd78e9b21d4e65232d5716cb09707b919f73e Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 17 Jun 2026 10:55:41 -0700 Subject: [PATCH 4/5] Make MatchContainer's loader prop optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no loader is provided, MatchContainer resolves componentModuleProvider functions using the environment's OperationLoader — the same cache that powers normalization AST loading. This means: - get() returns the component synchronously if already cached (e.g. from the normalization-time prefetch added in the previous commit) - If not cached, a Suspense-compatible load is triggered Users no longer need to define a custom loader function or manage a React.lazy cache. The loader prop is still accepted for custom resolution strategies. --- .../relay-hooks/MatchContainer.d.ts | 2 +- .../react-relay/relay-hooks/MatchContainer.js | 81 ++++++++++++++++++- .../data-driven-dependencies/module.md | 36 ++------- 3 files changed, 86 insertions(+), 33 deletions(-) diff --git a/packages/react-relay/relay-hooks/MatchContainer.d.ts b/packages/react-relay/relay-hooks/MatchContainer.d.ts index b1a7bdf52f534..776fbd826d6cc 100644 --- a/packages/react-relay/relay-hooks/MatchContainer.d.ts +++ b/packages/react-relay/relay-hooks/MatchContainer.d.ts @@ -83,7 +83,7 @@ export type MatchPointer = Readonly<{ export type MatchContainerProps, TFallback = ReactNode> = Readonly<{ fallback?: TFallback | null | undefined; - loader: (module: unknown) => ComponentType; + loader?: ((module: unknown) => ComponentType) | null | undefined; match?: MatchPointer | TypenameOnlyPointer | null | undefined; props?: TProps | undefined; }>; diff --git a/packages/react-relay/relay-hooks/MatchContainer.js b/packages/react-relay/relay-hooks/MatchContainer.js index e0d2430d66e98..c94a203af68d3 100644 --- a/packages/react-relay/relay-hooks/MatchContainer.js +++ b/packages/react-relay/relay-hooks/MatchContainer.js @@ -12,9 +12,80 @@ 'use strict'; const React = require('react'); +const useRelayEnvironment = require('./useRelayEnvironment'); const {useMemo} = React; +/** + * Cache for dynamically loaded components when using the default loader. + * Keyed by the componentModuleProvider function reference. + */ +type CacheEntry = + | {status: 'pending', promise: Promise} + | {status: 'fulfilled', value: React$AbstractComponent<{...}>} + | {status: 'rejected', reason: mixed}; + +const _dynamicImportCache: WeakMap< + () => Promise<{default?: mixed, ...}>, + CacheEntry, +> = new WeakMap(); + +/** + * Default loader for componentModuleProvider functions. Uses the + * environment's OperationLoader cache for sync resolution (populated by + * the prefetch in RelayResponseNormalizer), falling back to calling the + * import function and suspending until it resolves. + */ +function loadModuleComponent( + moduleRef: unknown, + operationLoader: ?{ + get(reference: unknown): mixed, + load(reference: unknown): Promise, + }, +): React$AbstractComponent<{...}> { + if (typeof moduleRef !== 'function') { + // Legacy string ref from a custom loader — pass through. + return (moduleRef: $FlowFixMe); + } + + // Try the OperationLoader cache first (populated at normalization time). + if (operationLoader != null) { + const cached = operationLoader.get(moduleRef); + if (cached != null) { + return (cached: $FlowFixMe); + } + } + + // Check our own Suspense cache. + const providerFn: () => Promise<{default?: mixed}> = (moduleRef: $FlowFixMe); + const entry = _dynamicImportCache.get(providerFn); + if (entry != null) { + if (entry.status === 'fulfilled') { + return entry.value; + } + if (entry.status === 'rejected') { + throw entry.reason; + } + // Still pending — suspend. + throw entry.promise; + } + + // First call — start loading and suspend. + const newEntry: CacheEntry = ({status: 'pending', promise: (null: $FlowFixMe)}: $FlowFixMe); + newEntry.promise = providerFn().then( + mod => { + newEntry.status = 'fulfilled'; + newEntry.value = (mod != null && mod.default != null ? mod.default : mod: $FlowFixMe); + }, + err => { + newEntry.status = 'rejected'; + newEntry.reason = err; + }, + ); + _dynamicImportCache.set(providerFn, newEntry); + throw newEntry.promise; +} + /** * Renders the results of a data-driven dependency fetched with the `@match` * directive. The `@match` directive can be used to specify a mapping of @@ -98,7 +169,7 @@ export type MatchContainerProps< TFallback extends React.Node, > = { readonly fallback?: ?TFallback, - readonly loader: (module: unknown) => component(...TProps), + readonly loader?: ?(module: unknown) => component(...TProps), readonly match: ?MatchPointer | ?TypenameOnlyPointer, readonly props?: TProps, }; @@ -138,8 +209,14 @@ component MatchContainer< ); } + const environment = useRelayEnvironment(); + const operationLoader = environment.getStore().getOperationLoader(); const LoadedContainer = - __module_component != null ? loader(__module_component) : null; + __module_component != null + ? loader != null + ? loader(__module_component) + : loadModuleComponent(__module_component, operationLoader) + : null; const fragmentProps = useMemo(() => { // TODO: Perform this transformation in RelayReader so that unchanged diff --git a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md index 8569f3e74d3fc..734391848414b 100644 --- a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md +++ b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md @@ -107,14 +107,11 @@ The `@module(name: ...)` value is set to a relative path that resolves from the source file to the component. The compiler embeds this as an `import()` call in the normalization AST (resolved relative to `__generated__/`). -The `Environment` provides a default `OperationLoader` that handles -`() => import(...)` function references, so no explicit `operationLoader` -configuration is needed. - -`MatchContainer`'s `loader` receives the `componentModuleProvider` function -that the normalizer stored in the Relay record. We wrap it with `React.lazy` -for Suspense-based async loading — the same pattern a production app would use -for code splitting. +No boilerplate is needed: +- The `Environment` provides a default `OperationLoader` that handles the + compiler's `() => import(...)` references. +- `MatchContainer` uses the environment's `OperationLoader` to resolve + components via Suspense when no explicit `loader` prop is provided. ```tsx title="App.tsx" import React, { Suspense } from "react"; @@ -128,21 +125,6 @@ import { gratsNetwork } from "../GratsNetwork"; const testEnvironment = new Environment({ network: gratsNetwork }); -// MatchContainer's loader receives the componentModuleProvider function -// (e.g. () => import('../MarkdownRendererView')) that the normalizer stored -// in the Relay record. Wrap it with React.lazy for Suspense-based loading. -const lazyCache = new Map>(); - -function moduleLoader(ref: unknown): React.ComponentType { - if (!lazyCache.has(ref)) { - lazyCache.set( - ref, - React.lazy(ref as () => Promise<{ default: React.ComponentType }>), - ); - } - return lazyCache.get(ref)!; -} - function App() { const data = useLazyLoadQuery( graphql` @@ -160,13 +142,7 @@ function App() { {}, ); - return ( - - ); + return ; } export default function TestApp() { From 2ff37cba854aae33d30496cc2ad00cbeefee8157 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 17 Jun 2026 10:57:14 -0700 Subject: [PATCH 5/5] Export MatchContainer from react-relay MatchContainer is now importable directly from react-relay instead of the deep path react-relay/relay-hooks/MatchContainer. Added to both index.js and hooks.js exports. --- packages/react-relay/hooks.js | 2 ++ packages/react-relay/index.js | 2 ++ .../relay-e2e-test/fixtures/data-driven-dependencies/module.md | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-relay/hooks.js b/packages/react-relay/hooks.js index c89de245236b9..ab7a1789596fe 100644 --- a/packages/react-relay/hooks.js +++ b/packages/react-relay/hooks.js @@ -14,6 +14,7 @@ /* eslint relay-internal/esm-compatible-cjs: error */ const EntryPointContainer = require('./relay-hooks/EntryPointContainer.react'); +const MatchContainer = require('./relay-hooks/MatchContainer'); const loadEntryPoint = require('./relay-hooks/loadEntryPoint'); const {loadQuery} = require('./relay-hooks/loadQuery'); const ProfilerContext = require('./relay-hooks/ProfilerContext'); @@ -91,6 +92,7 @@ module.exports = { requestSubscription, EntryPointContainer: EntryPointContainer, + MatchContainer: MatchContainer, RelayEnvironmentProvider: RelayEnvironmentProvider, ProfilerContext: ProfilerContext, diff --git a/packages/react-relay/index.js b/packages/react-relay/index.js index 0402bde31b0a1..a7e0e4d1c0174 100644 --- a/packages/react-relay/index.js +++ b/packages/react-relay/index.js @@ -24,6 +24,7 @@ const ReactRelayPaginationContainer = require('./ReactRelayPaginationContainer') const ReactRelayQueryRenderer = require('./ReactRelayQueryRenderer'); const ReactRelayRefetchContainer = require('./ReactRelayRefetchContainer'); const EntryPointContainer = require('./relay-hooks/EntryPointContainer.react'); +const MatchContainer = require('./relay-hooks/MatchContainer'); const loadEntryPoint = require('./relay-hooks/loadEntryPoint'); const {loadQuery} = require('./relay-hooks/loadQuery'); const ProfilerContext = require('./relay-hooks/ProfilerContext'); @@ -133,6 +134,7 @@ module.exports = { // Relay Hooks EntryPointContainer: EntryPointContainer, + MatchContainer: MatchContainer, RelayEnvironmentProvider: RelayEnvironmentProvider, ProfilerContext: ProfilerContext, diff --git a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md index 734391848414b..0f262f96e0e2e 100644 --- a/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md +++ b/packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md @@ -116,11 +116,11 @@ No boilerplate is needed: ```tsx title="App.tsx" import React, { Suspense } from "react"; import { + MatchContainer, RelayEnvironmentProvider, useLazyLoadQuery, } from "react-relay"; import { graphql, Environment } from "relay-runtime"; -import MatchContainer from "react-relay/relay-hooks/MatchContainer"; import { gratsNetwork } from "../GratsNetwork"; const testEnvironment = new Environment({ network: gratsNetwork });