Relay: [Modern] Client schema docs and examples

Created on 20 Apr 2017  路  51Comments  路  Source: facebook/relay

Any idea when docs and examples would be available?

Client Schema Extensions
The Relay Modern Core adds support for client schema extensions. These allow Relay to conveniently store some extra information with data fetched from the server and be rendered like any other field fetched from the server. This should be able to replace some use cases that previously required a Flux/Redux store on the side.

https://facebook.github.io/relay/docs/new-in-relay-modern.html#client-schema-extensions

docs

Most helpful comment

  • place anywhere inside src folder your local schema, give it .graphql extension for example local.graphql
  • add inside local schema your types and extend server types
# local.graphql
type Trace {
  id: ID!
  file: String
  line: Float
  what: String
  addr: String
}

type Error {
  id: ID!
  message: String
  prettyMessage: String
  operationName: String
  path: [String]
  trace: [Trace]
}

extend type Query {
  errors: [Error]
}
  • run relay compiler as usual relay-compiler --src ./src --schema {PATH_TO_SERVER_SCHEMA}

  • since now you can use local definitions above in your fragments or query

const query = graphql`
  query AppQuery {
    ...serverStuff_root
    ...otherServerStuff_root
    errors {
      id
      message
      prettyMessage
      operationName
      path
      trace {
        id
        file
        line
        what
        addr
      }
    }
  }
`;
  • now you need to add data in your relay store
import { commitLocalUpdate, ROOT_ID } from 'relay-runtime';

const ROOT_TYPE = '__Root';
const ERR_TYPE = 'Error';
const TRACE_TYPE = 'Trace';

function addErrs(environment, errs) {

    commitLocalUpdate(this.environment, s => {
      let root = s.get(ROOT_ID);
      if (!root) {
        root = s.create(ROOT_ID, ROOT_TYPE);
      }

      const errRecords = errs.map(err => {
        const errRecord = s.create(err.id, ERR_TYPE);
        errRecord.setValue(err.id, 'id');
        errRecord.setValue(err.message, 'message');
        errRecord.setValue(err.prettyMessage, 'prettyMessage');
        errRecord.setValue(err.operationName, 'operationName');
        errRecord.setValue(err.path, 'path');

        /*
        file: String
        line: Float
        what: String
        addr: String
        */
        const traceRecords = err.trace.map(traceLine => {
          const traceLineRecord = s.create(traceLine.id, TRACE_TYPE);
          traceLineRecord.setValue(traceLine.id, 'id');
          traceLineRecord.setValue(traceLine.file, 'file');
          traceLineRecord.setValue(traceLine.line, 'line');
          traceLineRecord.setValue(traceLine.what, 'what');
          traceLineRecord.setValue(traceLine.addr, 'addr');
          return traceLineRecord;
        });

        errRecord.setLinkedRecords(traceRecords, 'trace');
        return errRecord;
      });

      const existingErrRecords = root.getLinkedRecords('errors') || [];

      root.setLinkedRecords([...errRecords, ...existingErrRecords], 'errors');
    });


    // Hack to prevent records being disposed on GC
    // https://github.com/facebook/relay/issues/1656#issuecomment-380519761
    errs.forEach(err => {
      const dErr = this.store.retain({
        dataID: err.id,
        node: { selections: [] },
        variables: {},
      });

      const dTraces = err.trace.map(tLine =>
        this.store.retain({
          dataID: tLine.id,
          node: { selections: [] },
          variables: {},
        }),
      );

      this.disposableMap[err.id] = () => {
        dErr.dispose();
        dTraces.forEach(dT => dT.dispose());
      };
    });
}
  • don't forget to call dispose i.e this.disposableMap[err.id]() on err removal
  • You win, you don't need redux etc ;-) you can use relay for all state management

All 51 comments

馃憤 I was trying to find any reference to how to do "client schema extensions" but failed.

My suspicion is it'll happen in an example in RelayExamples before hitting official documentation here. I don't have a timeline, but that's your best bet for taking advantage of this feature. It already works (at least, it works on my machine :)), and you can find references to it via things like the RelayFilterDirectivesTransform, but getting this to work outside the FB environment hasn't yet happened.

I would like to know more about this to as well.

This is what I figured out till now

1) extendSchema function to extend schema. Also it's interesting to note that transformers are using this function.

2) From the tests it looks like lookup, and commitPayload can be used to read and save data.

Am I missing anything else?

I tried documenting this the other day and realized that there is an issue with the OSS compiler that prevents schema extensions from being defined. Once this is fixed we can document this feature.

Cc @kassens

@unirey The idea is that you can do something like this:

// client-schema.graphql
extend type SomeType {
  client: String
}

// compile script:
relay-compiler --schema schema.graphql --client-schema client-schema.graphql ...

// container fragment
graphql`
  fragment on SomeType {
    client
  }
`

// mutation updater
function updater(store) {
  const someValue = store.get(idOfValueOfSomeType);
  someValue.setValue("Foo", "client");  // value, field name
}

The missing part is that the oss compiler binary doesn't expose a way to tell the compiler about the client schema - all the primitives exist in the compiler to support client fields, and the runtime supports them as regular fields.

The client schema can also define new types: you can do

extend type Foo { field: NewType} 
type NewType { ... }

Hi there
Please, do you have any news and/or roadmap about this compiler feature?

FYI anyone new to react / relay looking for a temporary workaround, a "dirty" solution as suggested in https://github.com/facebook/relay/issues/1787 is to use context at the application root

@josephsavona In terms of changes, we're looking at :

https://github.com/facebook/relay/blob/master/packages/relay-compiler/generic/core/RelayCompiler.js#L52

class RelayCompiler {
  _context: RelayCompilerContext;
  _schema: GraphQLSchema;
  _transformedQueryContext: ?RelayCompilerContext;
  _transforms: CompilerTransforms;

  // The context passed in must already have any Relay-specific schema extensions
  constructor(
    schema: GraphQLSchema,
    context: RelayCompilerContext,
    transforms: CompilerTransforms,
  ) {
    this._context = context;
    // some transforms depend on this being the original schema,
    // not the transformed schema/context's schema
    this._schema = schema;
    this._transforms = transforms;
  }

We currently only have schema and context so we'll have to add in clientSchema and clientContext yes? Then manage these separately? Or do we have to have to merge the two schemas?

@chadfurman The compiler is configured to treat this._schema as the canonical server schema, while this._context has its own copy of the schema that may include client extensions. So I think what we probably want is to add an optional clientSchema: string constructor argument. Each time the context is rebuilt, do a final pass to parse the client schema and extend the this._context's schema. Something like these lines to parse the client schema text into definitions and add them to context. Maybe do this here (or similar), ie constructing a fresh context just before actually running it through all the transforms.

@josephsavona Im trying to follow the discussion. Are the steps you mentioned only ones left?

The missing part is that the oss compiler binary doesn't expose a way to tell the compiler about the client schema [...]

This is still missing? I don't even know what oss compiler binary exactly stands for or where to look.

@adjourn I'd guess this is the OSS compiler: https://github.com/facebook/relay/tree/master/packages/relay-compiler

This would be a super helpful feature.

Does the oss compiler support client schema extensions yet?

Anyone who didn't notice this thread, also, it has some useful details w.r.t. client schema: https://github.com/facebook/relay/issues/1787

I've been storing lots of client-side data either directly in component state, or as user preferences in the DB if it needs to be shared with other components

@jstejada any docs about this part?

Hey @sibelius, unfortunately we haven't made any docs about this yet. The limitation mentioned earlier in this thread still stands. We'll keep you posted with any updates :)

This is roughly what needs to happen in the compiler: https://github.com/kassens/relay/commit/a51f9f22d83c79b9b08100c866dbb84da210eac1

That would start reading *.graphql files from somewhere where one might define schema extensions of the form:

extend type User {
  isTyping: boolean
}

This extra field can then be queried as usual using fragment containers and updated using commitLocalUpdate (permanent change to the store as opposed to optimistic updates that can be rolled back), approximately like this:

require('react-relay').commitLocalUpdate(environment, store => {
   store.get('some-user-id').setValue(true, 'isTyping');
});

(I'm typing this from memory and haven't actually tested this, but given there's a bunch of interest and this has been too long on the back burner for us, I figured someone might be interested to take a look).

@kassens @brysgo did this to make client extensions works: https://github.com/brysgo/create-react-app/blob/master/packages/react-scripts/scripts/relay.js#L75

does this look reasonable?

here a sample example of it https://github.com/brysgo/relay-example

@sibelius just mixing the client schema into the server schema won鈥檛 work: the compiler needs to know which fields/types are from which schema in order to strip out client-only portions of queries that it sends to the server. Th compiler binary needs to change to accept a second argument (eg 鈥攃lient-schema) and use that appropriately.

Check the network layer in the example, it filters the local stuff. There is probably a better way, but it works.

I opened a PR with some slight changes to what @kassens did. https://github.com/facebook/relay/pull/2264. I tested it on the Relay Todo app in the relayjs-examples repo. I can define extensions and see them being added to the concrete fragments but removed for the final query so seems to work.

My example has been updated to use @edvinerikson's changes in #2264 if anyone is here looking for an example.

any PR's to add docs for this would be accepted! The only important thing to note would be that client schema extensions only currently support additions to existing types and not adding entirely new types

I'm confused, isn't there still a limitation where the compiler needs to be altered to allow for client schema? Or was that solved in #2264? Is the only step now to get things documented?

notwithstanding the limitation pointed out above, it is solved.

@tslater That鈥檚 correct, it was solved in #2264 and the only thing needed now is documentation.

@alloy Thanks for clarifying. I just noticed this in the release notes:

Extensions only work on existing types, and does not currently support adding client-only types to the schema.

Does that mean one needs to put any types they're using client side in their server/other schemas?

I believe so, yes. Maybe @edvinerikson can better help you with the details.

I haven't tested adding new types, but I don't think there is any limitation. The compiler just takes all the extra documents and merges them into a complete schema (together with server schema). To also check if a type is from the server, they also track the server schema separately as well.

https://github.com/facebook/relay/blob/master/packages/relay-compiler/codegen/RelayFileWriter.js#L120
https://github.com/facebook/relay/blob/master/packages/relay-compiler/graphql-compiler/transforms/SkipClientFieldTransform.js#L115
https://github.com/facebook/relay/blob/master/packages/relay-compiler/graphql-compiler/core/GraphQLCompilerContext.js#L44

should we open another issue to track adding new types to client schema extentions?

@edvinerikson the main limitation right now is that records of those types will always get garbage collected, I /think/

@sibelius yeah I think that's a good idea

What is the good way to prevent records to be collected from store?

i.e.

  commitLocalUpdate(environment, s => {
    const err = s.create('4', 'Error');
    err.setValue('4', 'id');
    err.setValue('Error message', 'message');
    root.setLinkedRecords([err], 'errors');
  });

errors array after gc contains undefined.

But If I add code below

    store.retain({
      dataID: '4',
      node: { selections: [] },
      variables: {},
    });

this prevents records deletion

As I am reading through this issue it is not clear to me whether relay-compiler version 1.5.0 which was updated 2 months ago on npm supports client schema or not. The release notes for version 1.5.0 mention client schema but running relay-compiler does not display the actual flag.

It supports client schemas well, we use this feature in development.

Could anyone provide some meaningful examples, since docs are lacking?

  • place anywhere inside src folder your local schema, give it .graphql extension for example local.graphql
  • add inside local schema your types and extend server types
# local.graphql
type Trace {
  id: ID!
  file: String
  line: Float
  what: String
  addr: String
}

type Error {
  id: ID!
  message: String
  prettyMessage: String
  operationName: String
  path: [String]
  trace: [Trace]
}

extend type Query {
  errors: [Error]
}
  • run relay compiler as usual relay-compiler --src ./src --schema {PATH_TO_SERVER_SCHEMA}

  • since now you can use local definitions above in your fragments or query

const query = graphql`
  query AppQuery {
    ...serverStuff_root
    ...otherServerStuff_root
    errors {
      id
      message
      prettyMessage
      operationName
      path
      trace {
        id
        file
        line
        what
        addr
      }
    }
  }
`;
  • now you need to add data in your relay store
import { commitLocalUpdate, ROOT_ID } from 'relay-runtime';

const ROOT_TYPE = '__Root';
const ERR_TYPE = 'Error';
const TRACE_TYPE = 'Trace';

function addErrs(environment, errs) {

    commitLocalUpdate(this.environment, s => {
      let root = s.get(ROOT_ID);
      if (!root) {
        root = s.create(ROOT_ID, ROOT_TYPE);
      }

      const errRecords = errs.map(err => {
        const errRecord = s.create(err.id, ERR_TYPE);
        errRecord.setValue(err.id, 'id');
        errRecord.setValue(err.message, 'message');
        errRecord.setValue(err.prettyMessage, 'prettyMessage');
        errRecord.setValue(err.operationName, 'operationName');
        errRecord.setValue(err.path, 'path');

        /*
        file: String
        line: Float
        what: String
        addr: String
        */
        const traceRecords = err.trace.map(traceLine => {
          const traceLineRecord = s.create(traceLine.id, TRACE_TYPE);
          traceLineRecord.setValue(traceLine.id, 'id');
          traceLineRecord.setValue(traceLine.file, 'file');
          traceLineRecord.setValue(traceLine.line, 'line');
          traceLineRecord.setValue(traceLine.what, 'what');
          traceLineRecord.setValue(traceLine.addr, 'addr');
          return traceLineRecord;
        });

        errRecord.setLinkedRecords(traceRecords, 'trace');
        return errRecord;
      });

      const existingErrRecords = root.getLinkedRecords('errors') || [];

      root.setLinkedRecords([...errRecords, ...existingErrRecords], 'errors');
    });


    // Hack to prevent records being disposed on GC
    // https://github.com/facebook/relay/issues/1656#issuecomment-380519761
    errs.forEach(err => {
      const dErr = this.store.retain({
        dataID: err.id,
        node: { selections: [] },
        variables: {},
      });

      const dTraces = err.trace.map(tLine =>
        this.store.retain({
          dataID: tLine.id,
          node: { selections: [] },
          variables: {},
        }),
      );

      this.disposableMap[err.id] = () => {
        dErr.dispose();
        dTraces.forEach(dT => dT.dispose());
      };
    });
}
  • don't forget to call dispose i.e this.disposableMap[err.id]() on err removal
  • You win, you don't need redux etc ;-) you can use relay for all state management

Wow, thanks @istarkov , you are the * Boss!

Also we use local type extensions, to add local properties to existing objects like isNew, then setting isNew in mutation updater, we can show that object just created, (updated) etc so you can use local schemes even without commitLocalUpdate

@istarkov Thanks for that, though, arguably not as straightforward as say, the way Apollo GraphQL does it. And from the looks of it, you can't do things like mutations?

@MrSaints I haven't tested the ability to have a fully compatible mutation interface. BTW if you can extend mutations (I don't know can you or not (not at computer right now)) it will be not a problem at all, just intercept them at network level and do any stuff with store.
Also IMO it's not a big deal to write more user friendly interface over the store for example like immerjs do with proxies https://github.com/mweststrate/immer/blob/master/src/proxy.js so all that store stuff can be hided via usual js operations.

I found @istarkov findings super useful but didn't know where this.store comes from ... So I did a little research and found that in Relay 1.6.0 retain method is available directly from environment.

const dErr = environment.retain({
    dataID: err.id,
    node: { selections: [] },
    variables: {},
});

I also found that you can access store from environment, useful if you want to test changes like the following

describe("handleChange(field, value)", () => {
  test("updates userForm.user in local Relay store", () => {
    const firstName = "Roberto";
    const container = shallow(<UserForm relay={{ environment }} />).instance();
    container.handleChange("firstName", firstName);

    expect(
      environment
        .getStore()
        .getSource()
        .get("userForm:user").firstName
    ).toEqual(firstName);
  });
});

Just for the record relay-mock-network-layer is really useful for this kind of tests.

This post on medium is really helpful too https://medium.com/@matt.krick/replacing-redux-with-relay-47ed085bfafe

thanks for the shoutout @JCMais! just a heads up, the client schema is ___not___ safe to use for fields that are mutated by server & optimistic updates. I wrote a fix for it here (https://github.com/facebook/relay/pull/2482) but it's been in PR purgatory for a few months :cry:

Since @istarkov's comment is a bit dated now I was wondering if there was an easier way to set the initial values for the client state.

As @mattkrick and @josephsavona are working tough on #2482 - as we might see some improvements soon, it might be good time to start documenting those experimental features on that ### pull request馃棥

can we have a fetchFnClient in network layer that handle custom resolvers on client schema extensions?

fetchFnClient would receive resolvers that are not in server schema

you can use this helper

export const setLocal = (query: GraphQLTaggedNode, localData: object) => {
  const request = getRequest(query);
  const operation = createOperationDescriptor(request, {});

  env.commitPayload(operation, localData);
  env.retain(operation.root);  // <== here @en_js magic :wink:
};

To make sure relay does not garbage collect same graphql operation

more blog posts here

https://babangsund.com/relay_local_state_management/
https://babangsund.com/relay_local_state_management_2/

anybody want to improve the docs with all this info?

@sibelius thanks for keeping this alive!

Using hooks for local data is pretty easy, too. That way you don't need a clunky QueryRenderer.

const data = useLocalQuery<SnackbarQuery>(query)
const useLocalQuery = <TQuery extends {response: any; variables: any}>(
  environment: Environment,
  query: any,
  inVariables: TQuery['variables'] = {}
): TQuery['response'] | null => {
  const variables = useDeepEqual(inVariables)
  const [dataRef, setData] = useRefState<SelectorData | null>(null)
  const disposablesRef = useRef<Disposable[]>([])
  useEffect(() => {
    const {getRequest, createOperationDescriptor} = environment.unstable_internal
    const request = getRequest(query)
    const operation = createOperationDescriptor(request, variables)
    const res = environment.lookup(operation.fragment, operation)
    setData(res.data || null)
    disposablesRef.current.push(environment.retain(operation.root))
    disposablesRef.current.push(
      environment.subscribe(res, (newSnapshot) => {
        setData(newSnapshot.data || null)
      })
    )
    const disposables = disposablesRef.current
    return () => {
      disposables.forEach((disposable) => disposable.dispose())
    }
  }, [environment, setData, query, variables])
  return dataRef.current
}

we now have official docs about how to use client schema extensions:

https://relay.dev/docs/en/next/local-state-management

thanks to @babangsund for the great work exploring and documenting this

Was this page helpful?
0 / 5 - 0 ratings