Skip to content
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/react-relay/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

/* eslint relay-internal/esm-compatible-cjs: error */

const EntryPointContainer = require('./relay-hooks/EntryPointContainer.react');

Check failure on line 16 in packages/react-relay/hooks.js

View workflow job for this annotation

GitHub Actions / JS Lint

Requires should be sorted alphabetically, with at least one line between imports/requires and code

Check failure on line 16 in packages/react-relay/hooks.js

View workflow job for this annotation

GitHub Actions / JS Lint

Requires should be sorted alphabetically, with at least one line between imports/requires and code
const MatchContainer = require('./relay-hooks/MatchContainer');
const loadEntryPoint = require('./relay-hooks/loadEntryPoint');
const {loadQuery} = require('./relay-hooks/loadQuery');
const ProfilerContext = require('./relay-hooks/ProfilerContext');
Expand Down Expand Up @@ -91,6 +92,7 @@
requestSubscription,

EntryPointContainer: EntryPointContainer,
MatchContainer: MatchContainer,
RelayEnvironmentProvider: RelayEnvironmentProvider,

ProfilerContext: ProfilerContext,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-relay/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@

/* eslint relay-internal/esm-compatible-cjs: error */

const ReactRelayContext = require('./ReactRelayContext');

Check failure on line 20 in packages/react-relay/index.js

View workflow job for this annotation

GitHub Actions / JS Lint

Requires should be sorted alphabetically, with at least one line between imports/requires and code

Check failure on line 20 in packages/react-relay/index.js

View workflow job for this annotation

GitHub Actions / JS Lint

Requires should be sorted alphabetically, with at least one line between imports/requires and code
const ReactRelayFragmentContainer = require('./ReactRelayFragmentContainer');
const ReactRelayLocalQueryRenderer = require('./ReactRelayLocalQueryRenderer');
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');
Expand Down Expand Up @@ -133,6 +134,7 @@

// Relay Hooks
EntryPointContainer: EntryPointContainer,
MatchContainer: MatchContainer,
RelayEnvironmentProvider: RelayEnvironmentProvider,

ProfilerContext: ProfilerContext,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-relay/relay-hooks/MatchContainer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export type MatchPointer = Readonly<{

export type MatchContainerProps<TProps = Record<string, unknown>, TFallback = ReactNode> = Readonly<{
fallback?: TFallback | null | undefined;
loader: (module: unknown) => ComponentType<TProps>;
loader?: ((module: unknown) => ComponentType<TProps>) | null | undefined;
match?: MatchPointer | TypenameOnlyPointer | null | undefined;
props?: TProps | undefined;
}>;
Expand Down
81 changes: 79 additions & 2 deletions packages/react-relay/relay-hooks/MatchContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,81 @@

'use strict';

const React = require('react');

Check failure on line 14 in packages/react-relay/relay-hooks/MatchContainer.js

View workflow job for this annotation

GitHub Actions / JS Lint

Requires should be sorted alphabetically, with at least one line between imports/requires and code

Check failure on line 14 in packages/react-relay/relay-hooks/MatchContainer.js

View workflow job for this annotation

GitHub Actions / JS Lint

Requires should be sorted alphabetically, with at least one line between imports/requires and code
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<void>}
| {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<mixed>,
},
): 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
Expand Down Expand Up @@ -98,7 +169,7 @@
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,
};
Expand Down Expand Up @@ -138,8 +209,14 @@
);
}

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
Expand Down
4 changes: 2 additions & 2 deletions packages/relay-e2e-test/__tests__/fixtures-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions packages/relay-e2e-test/fixtures/data-driven-dependencies/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# 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 <div>Markdown: {data.markdown}</div>;
}
```

## 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 <div>Plain: {data.plaintext}</div>;
}
```

## 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__/`).

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";
import {
MatchContainer,
RelayEnvironmentProvider,
useLazyLoadQuery,
} from "react-relay";
import { graphql, Environment } from "relay-runtime";
import { gratsNetwork } from "../GratsNetwork";

const testEnvironment = new Environment({ network: gratsNetwork });

function App() {
const data = useLazyLoadQuery<any>(
graphql`
query AppQuery {
viewer {
nameRenderer {
...MarkdownRendererView_name
@module(name: "./MarkdownRendererView")
...PlainTextRendererView_name
@module(name: "./PlainTextRendererView")
}
}
}
`,
{},
);

return <MatchContainer match={data.viewer?.nameRenderer} />;
}

export default function TestApp() {
return (
<RelayEnvironmentProvider environment={testEnvironment}>
<Suspense fallback={<div>Loading...</div>}>
<App />
</Suspense>
</RelayEnvironmentProvider>
);
}
```

## Steps

```steps
wait "Markdown:"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## HTML

```html
<div>Markdown: **Hello, world!**</div>
```
9 changes: 6 additions & 3 deletions packages/relay-e2e-test/jest-transform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/relay-e2e-test/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ module.exports = {
'<rootDir>/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: ['<rootDir>'],
Expand Down
1 change: 1 addition & 0 deletions packages/relay-runtime/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/relay-runtime/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -422,6 +423,7 @@ module.exports = {
// Utilities
PreloadableQueryRegistry,
RelayProfiler: RelayProfiler,
createOperationLoader: createOperationLoader,
createPayloadFor3DField: createPayloadFor3DField,

// INTERNAL-ONLY: These exports might be removed at any point.
Expand Down
Loading
Loading