houdini icon indicating copy to clipboard operation
houdini copied to clipboard

Support SvelteKit's new API

Open AlecAivazis opened this issue 1 year ago β€’ 3 comments

This PR updates houdini's APIs to be compatible with the updates to SvelteKit as described here: https://github.com/sveltejs/kit/discussions/5774. Unfortunately, I won't be able to get the integration tests to pass until the update is released but I'll try to do as much as I can until then.

Just to get some points out of the way for people worried about major breaking changes:

  • SvelteKit projects will now need to use the vite plugin defined at houdini/kit which replaces the old preprocessor (and the ugly server.fs.allow config). But don't worry, there are lots of really cool things that we're getting with this move.
  • The external document store api for SvelteKit routes is changing. This means that if you rely heavily on the document stores, you will have to update your load functions but as a result you can delete a lot of the code in your components and the overall mental model is a lot simpler (no more passing around context)
  • inline queries are staying the same. At a high level, everything that used to be defined in context="module" is now defined in +page.js/ts.

For additional breaking changes and notes, check out the section at the bottom of this. We know this is a lot of changes but we have been thinking about some of these for awhile and since SvelteKit has forced us to change the API, it seemed like a good time to shift some things around.

Vite Plugin

For kit projects, the preprocessor has moved to houdini/kit and should be included as a plugin in your vite.config.js. You should also remove the the fs.server.allow config since its baked into the plugin. This plugin not only processes our components and javascript files, but it also integrates into vite's dev script to bring some huge improvements:

  • automatically generates your artifacts and stores whenever it detects a change in your source code. if you have configured your own watcher, it might not be necessary anymore.
  • poll your api for changes in your schema and regenerate when they are detected. This behavior is customizable between 3 modes: a duration in milliseconds, only pull when the project first starts, and never pull.

Ideally, you shouldn't need to run generate ever again! It'll even get run when you build your application with vite build so feel free to delete that generate script from your package.json if you have it.

New Store API

The core of this change is that instead of using stores that you import, your component will now get the store from its props. This is the full example for the component-side of a store-driven route:

<script>
  /** @type {import('./$types').Data */
  export let data: Data;

  $: ({ UserInfo } = data);
</script>

{$UserInfo.data.firstName}

Notice there's no more need to thread variables around or that ugly $: browser && store.fetch.

In order to make this work, your load functions need to now return instances of the store embedded in the correct key. This can be easily done with the new load_StoreName functions. The load_ prefix is to make it easy to autocomplete when you are working in your editor:

import { load_UserInfo } from '$houdini'

export async function load(event) {
    return { 
        ...await load_UserInfo({ event })
    }
}

This means that when adding a query to a route, tasks like adding a store to a load, pulling that store out of data in your component, etc can be auto completed from just starting with load_ in your route's load function. It does make the manual logic that uses a fetch result kind of messy since they have to pull out UserInfo but I think that’s a rare situation so I’m in favor of supporting the most people with the clean API and letting the advanced users deal with the complexity.

Also, on a slightly unrelated note: you don't need to pass context everywhere anymore! that all happens behind the scenes now.

Page Queries

You can now define a +page.gql file inside of a route directory to automatically opt-into a generated load without having to do anything else:

# src/routes/myProfile/+page.gql

query MyQuery { 
	viewer { 
		id
	}
}

With that file in place, you can just import and use the MyQuery store passed to your route and the view will be SSR'd automatically.

Inline Stores

The graphql template tag can now be used to define your stores in your javascript or svelte files:

<!-- src/routes/myProfile/+page.svelte -->

<script>
	// this is only a store. no fetching has happened behind the scenes here 
	const store = graphql`
		query ViewerInfo { 
			viewer { 
				id
			}
		}
	`
</script>

id: {$store.data?.viewer.id}

<button onClick={store.fetch} />

Generating Loads For Stores

You can now tell the houdini plugin to generate loads for your stores. To do this, you need to export a houdini_load variable from your +page.js/ts file:

// src/routes/myProfile/+page.ts
import { GQL_MyQuery, GQL_Query1, GQL_Query2 } from '$houdini'

export const houdini_load = GQL_MyQuery 
// or 
export const houdini_load = [ GQL_Query1, GQL_Query2 ]

This can be mixed with the new graphql tag api to define your queries inside of your javascript files:

import { GQL_MyQuery, graphql. } from '$houdini'

const otherQuery = graphql`
	query ViewerInfo { 
		viewer { 
			id
		}
	}
`

export const houdini_load = [ GQL_MyQuery, otherQuery ]

or

// src/routes/myProfile/+page.ts

export const houdini_load = graphql`
	query ViewerInfo { 
		viewer { 
			id
		}
	}
`

Breaking Changes / Notes

  • configuration for inline queries (variable functions, hooks, etc.) go in +page.js
  • inline fragments have a reversed order for arguments
  • config.sourceGlob is no longer required and has been deprecated in favor of config.include which has a default value that covers most projects
  • added config.exclude to filter out files that match the include pattern
  • generate --pull-header is now generate --header (abbreviated -h)
  • generate --persist-output is now generate --output (abbreviated -o)
  • added schemaPullHeaders config value to specify the headers sent when pulling the schema
  • svelte projects not using kit should now import the preprocessor from houdini/svelte-preprocess

Progress

  • [ ] missing integration tests
    • [ ] houdini_load with inline stores next to global stores
    • [ ] houdini_load list vs single
    • [ ] loadAll
    • [ ] page query
  • [ ] verify hmr from houdini_load
  • [ ] this.error and this.redirect in hooks and variable functions (placeholders are there)
  • [x] provide context whenever possible
  • [x] loadAll(load_foo, { val1: load_bar})
  • [x] make sure event.parent is accessible everywhere
  • [x] invert fragment order
  • [x] make copy friendlier
  • [x] new version message with link to migration guide
  • [x] test/handle aliased imports in +page.ts files (strip away all non-houdini related things when creating manifest?)
  • [x] embedded store references
    • [x] remove session protection
    • [x] update plugin to generate correct loads
    • [x] update plugin to update routes
  • [x] add pull-schema command
  • [x] config option for page query file name
  • [x] deprecate sourceGlob in favor of include and exclude
  • [x] rename houdni/vite to something more svelte specific (went with houdini/kit)
  • [x] support typescript +page.ts
  • [x] support a single or multiple entries in houdini_load
  • [x] check if we can rename page.gql to +page.gql
  • [x] verify return value props in generated load
  • [x] $$props in __layout.svelte error
  • [x] ~smart typing for graphql tag~ template tag overloads aren't supported by typescript yet
  • [x] try to hide +page.js using svemix trick
  • [x] avoid graphql template tag runtime exception in plugin (actually make import work)
  • [x] Preprocessor tasks in plugin
    • [x] inline query
    • [x] export const houdini_load = [...]
    • [x] define page query in page.gql
    • [x] ~fragment updates~ not needed since reactive expression is the blessed api
    • [x] component query inputs
    • [x] graphql tags produce store references
  • [x] update inline functions to use stores directly
  • [x] schema watcher
  • [x] generate +page.js or +page.ts
  • [x] support old preprocessor package using vite plugin
  • [x] Update init for svelte projects
  • [x] move houdini/preprocess to houdini/svelte-preprocess
  • [x] update preprocessor tests

Documentation Updates

  • [ ] config values
    • [ ] typescript
    • [ ] schema poll interval
    • [ ] schema poll headers
  • [ ] no more mention of context="module"
  • [ ] export const houdini_load
  • [ ] +page.gql
  • [ ] pull-schema command
  • [ ] setting up your project
    • [ ] use houdini/vite for kit
    • [ ] houdini plugin must come before sveltekit
    • [ ] vanilla svelte projects need to use houdini/svelte-preprocess
  • [ ] remove mentions of sapper
  • [ ] migration guide
  • [ ] The variable API isn't weird any more!!!!
  • [ ] hooks no longer return props
  • [ ] inverted fragment order
  • [ ] new api for loading stores
    • [ ] loadAll
  • [ ] update getting started guide?
  • [ ] endpoint fetch is optional
  • [ ] context is no longer required

fixes #398, fixes #428, fixes #325, fixes #242

AlecAivazis avatar Aug 02 '22 07:08 AlecAivazis

πŸ¦‹ Changeset detected

Latest commit: 5575eae5c249330c7fdb8d23bdb361a97703486c

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

changeset-bot[bot] avatar Aug 02 '22 07:08 changeset-bot[bot]

Really nice that you start working on it parallel to their development. Keep up the great work πŸ‘

fehnomenal avatar Aug 02 '22 10:08 fehnomenal

The latest updates on your projects. Learn more about Vercel for Git β†—οΈŽ

Name Status Preview Updated
docs βœ… Ready (Inspect) Visit Preview Aug 25, 2022 at 3:09AM (UTC)
docs-next βœ… Ready (Inspect) Visit Preview Aug 25, 2022 at 3:09AM (UTC)

vercel[bot] avatar Aug 10 '22 22:08 vercel[bot]

Amazing changes, really hyped for the release!

david-plugge avatar Aug 20 '22 07:08 david-plugge

Wow @fehnomenal! Really appreciate all this help ❀️ If these are the kinds of comments we're getting we're in a great place already. Was scared the first messages would be something more like "wtf none of this works...." πŸ˜…

AlecAivazis avatar Aug 23 '22 16:08 AlecAivazis

Been eagerly checking out this PR for the past five days, great work!

PimTournaye avatar Aug 23 '22 17:08 PimTournaye

Wow @fehnomenal! Really appreciate all this help :heart: If these are the kinds of comments we're getting we're in a great place already. Was scared the first messages would be something more like "wtf none of this works...." :sweat_smile:

Well, to be honest at first I struggled a bit :sweat_smile: But after reading the docs I am really happy how smooth everything works :star_struck: Well at least when compared with the sveltekit changes :sweat_smile:

Been eagerly checking out this PR for the past five days, great work!

Same, that's why I decided to help getting this released already :joy:

fehnomenal avatar Aug 23 '22 18:08 fehnomenal

I need a way to pass an authorization header after logging in. Unfortunately the access token is neither written nor read to/from a cookie by the backend I cannot control (at least not to that extent). So, I have a store that contains the auth session (you guessed it previously it was just $session).

I propose to add a generic to HoudiniClient<RequestData> -- the name is meh, but there are already multiple contexts. Maybe simply RequestSession or Session? -- which determines the type of a var that is passed to fetchQuery:

export default new HoudiniClient<{
  accessToken: string | null | undefined;
}>(async ({ requestData, fetch, text, variables }) => {
  const headers: HeadersInit = {
    'content-type': 'application/json',
  };

  if (requestData.accessToken) {
    headers.authorization = `Bearer ${requestData.accessToken}`;
  }

  const response = await fetch('...', {
    method: 'POST',
    headers,
    body: JSON.stringify({ query: text, variables }),
  });

  return await response.json();
});

To fill this variable we can use the .init() method which still exists but does nothing. It could take a () => MaybePromise<RequestData> function and the client would await it in sendRequest (if present) and pass it to the fetchFn. Then my +layout.server.ts and +layout.ts could look like this:

// +layout.server.ts
import { getLoginUrl } from '$lib/utils/auth-urls';
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = ({ locals, url }) => {
  // locals.auth is set by `handle`
  if (!locals.auth) {
    throw redirect(302, getLoginUrl(url, undefined));
  }

  return {
    auth: {
      accessToken: locals.auth.accessToken,
      expiresAt: locals.auth.expiresAt.toJSON(),
      isAdmin: locals.auth.isAdmin,
    },
  };
};
// +layout.ts
import houdini from '$lib/houdini';
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = ({ data }) => {
  houdini.init(() => ({
    accessToken: data.auth.accessToken,
  }));
};

What do you think? Could this work?

fehnomenal avatar Aug 23 '22 19:08 fehnomenal

Before I reply to @fehnomenal, I just want to say how crazy it is to see all these reactions and comments. Feels really great to see people are as excited about this as we are.

Okay, @fehnomenal: you're killing it today. I really like this idea.

It seems like a clean answer to a question that's been looming over me for awhile. Now that all of the session data is supposed to come from load results, we needed some way to get data out of a load and into a fetch and this does exactly that. It also addresses a lot of the other issues I was struggling with like how do we keep the value up to date, etc.

I think the only real question is if init is a good name. Now that "session" is no longer a kit thing, maybe we can take it for ourselves? A quick note about init in case it sparks any ideas: atm we need some way of identifying when the initial hydration of your application is finished. There is an open issue that is slated to be part of sveltekit's 1.0 but until that's merged we have to use a browser event. This means that every possible route the user might load from needs to have this event listener (even if it doesn't use Houdini). We hide this nasty detail by trying to force users to somehow import the runtime into that first render so we can register the event (this happens in the sveltekit adapter if you're curious). Anyway, the docs say to run client.init but all that really matters is that they import the runtime, hence why init is now empty an empty function. My only concern with naming init something else is that a user that does not have custom session logic won't be inclined to call setSession or whatever and so they won't import the runtime. Having it be called something generic like init enforces that it needs to always happen. Maybe we just have 2 different functions for now and then one day init is just no longer "necessary"?

there are already multiple contexts

I was thinking about this last night and I'm pretty sure that we can actually merge most of the contexts into one and simplify that part of the codebase considerably. This is something that constantly confuses us when @jycouet and I are talking so it's worth taking the opportunity to simplify it.


Both of these are things I want to get in before we actually release 0.16.0 but I dont think they should block the first @next version. That being said, I think the HoudiniClient import issue and the errors in schema logic are big problems so I'm going to work on fixing those and then cut a version for more people to test out πŸŽ‰

edit: I pushed up https://github.com/HoudiniGraphql/houdini/pull/449/commits/b1261e4071025b2717a391175870e0a598868764 to break CI as a reminder to fix this but things seem to be building successfully (verified locally on my machine). I know this was a problem a few days ago but I thought went through and fixed the imports. Maybe you have an old version? Mind giving it a shot with the latest updates?

AlecAivazis avatar Aug 23 '22 20:08 AlecAivazis

Btw I want to make this clear: I absolutely love this project and am so grateful for all your work and effort you pour into your project. I absolutely don't want to discourage you but want to get the best GraphQL client for svelte in existence :smiley:

fehnomenal avatar Aug 23 '22 23:08 fehnomenal

Oh no problem at all! I love the feedback and questions!

I'm a bit busy right now so my reply might be a little quick but just to address the mutation question: t's mostly there for consistency.

I had the same question for myself about query and subscription which both basically just return the store. I figured we'd keep it there for this version while we feel out the new store API and maybe just remove them all together in the next API breaking bump. I'm a bit unsure about what to do with your comments atm. I was going to merge this PR and deploy using the changesets but I don't want to lose your feedback. I guess I'll just deploy from this branch manually once I fix the schema issues tonight πŸ‘

AlecAivazis avatar Aug 24 '22 00:08 AlecAivazis

Okay, I finally got some time to sit down and go through all this. I'm pretty certain all of the immediately actionable things have been addressed. Thanks again!

Mutation again

I'm convinced. But I want to take it a step further and also remove query, paginatedQuery, and subscription. Basically, only having the old fragment and paginatedFragment functions. The first group of 3 did nothing other than return the store and because of that we were paying the ambiguity cost you were talking about all over the codebase and documentation for literally no reason. it's better if we just leave only one way of working with graphql documents: graphql tags returning Svelte stores.

So that leaves one question: how does a store that's defined inline opt in/out of a generated load? What I'd like to do is add a new directive to the pile @houdini that we will use moving forward as the place to define one-off configurations (like #239 and #349). For this situation, I suggest we have @houdini(load: Boolean!) with a default value of true. This means a user can turn off the automatic load if they want a lazy inline query.

Route queries

There is a technical limitation here. Stores can't be serialized and so they cannot be embedded in the response from a server endpoint. At the moment, I'd like to leave this as a known limitation and we can explore what the right option is. This is an area of kit that's still very much in flux so I'd like to wait a little bit for the dust to settle before we start to jump through a bunch of hoops to hide that limitation from the user.

The type of AfterLoad is not correctly resolved

You definitely have a version of this release that predates the βœ…. The AfterLoad type definition has changed in order for the generated PageData type to include whatever you add there. I was honestly shocked to see this work (along with a few other things going on in this release tbh πŸ˜…). Anyway, it now looks like this:

import { type AfterLoadEvent } from '$houdini'

export function afterLoad({ data }: AfterLoadEvent) {
    return { 
        ...
    }
}

For manually loaded queries: How can I access the returned data inside the load function? The return value is the normal store. Should I just use get(Store).data? It works but get() always feels like a hack...

Moving this conversation to the discussion πŸ‘

AlecAivazis avatar Aug 24 '22 07:08 AlecAivazis

I'm going to work on merging this PR tonight so we can release the version with the changesets bot. I started this discussion for future conversations so we can thread messages and what not: https://github.com/HoudiniGraphql/houdini/discussions/475

Really appreciate all of the care and excitement here. It feels like we're really on the break of the something truly special.

AlecAivazis avatar Aug 24 '22 08:08 AlecAivazis

Re Afterload:

Hmm, I got this generated:

type AfterLoadReturn = ReturnType<typeof import('./+page').afterLoad>;

type AfterLoadData = {
	GetAvailableWorkshops: GetAvailableWorkshops$result
}

type AfterLoadInput = {
	GetAvailableWorkshops: GetAvailableWorkshops$input
}

export type AfterLoadEvent = {
	event: LoadEvent
	data: AfterLoadData
	input: AfterLoadInput
}

export type AfterLoad = AfterLoadFunction<Params, AfterLoadData, AfterLoadInput>

and assumed I can just use AfterLoad. This works fine:

export const afterLoad = ({ data }: AfterLoadEvent) => {
  console.log('afterLoad', data.GetAvailableWorkshops);
};

Maybe remove the generated AfterLoad? https://github.com/HoudiniGraphql/houdini/blob/dce9397cc93d76dd9adea92f3b8ec5b27dde0bb4/src/cmd/generators/kit/index.ts#L128

Ah well, the AfterLoadEvent['event'] is still untyped. In the end I will avoid using hooks for all these edge cases. With the new load_... functions writing the load function manually isn't really that much longer when you need variables and an after hook.

fehnomenal avatar Aug 24 '22 09:08 fehnomenal

Maybe remove the generated AfterLoad?

Good call!

Ah well, the AfterLoadEvent['event'] is still untyped.

I'm about to push a fix for this up right now (along with the removal of query and the rest). Just cleaning up the documentation

AlecAivazis avatar Aug 24 '22 09:08 AlecAivazis

I love all your recent changes. I just noticed one more thing:

Params is any image

I suppose this is because QueryStoreFetchParams expects two generic arguments but gets only one: image

fehnomenal avatar Aug 24 '22 12:08 fehnomenal

Okay, i finally got everything building and think this is finally ready to merge. Really appreciate everyone's patience. I think you're really gonna like what we've put together πŸŽ‰

AlecAivazis avatar Aug 25 '22 03:08 AlecAivazis