Skip to content
Open
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
91 changes: 91 additions & 0 deletions ErrorBoundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
Handover

Thats great Taz, collections look like they work really well for lists of data. However we are still having to props drill data into each of the pull request list cells and in a larger component this can really start scaling poorly, where the root holds all the context for its children.

And when an error throws it is handled at the root only causing the entire list to fail.

This is where fragments really come into their own, they allow us to scope the management of data more granularly to the component concerned with it.

break down the fragment into the component

```typescript
onst PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => {
const data = useFragment(
graphql`
fragment PullRequest_pr on PullRequest {
id
number
title
url
state
isDraft
createdAt
updatedAt
repository {
name
nameWithOwner
}
baseRefName
headRefName
additions
deletions
reviewDecision
}
`,
pr
);
```

When we do this we also get a more granual approach to error handling.

So what happens when we things dont go as expected and we get an error in our data? For example, what we don't have permission to access a particular piece of data? How do we handle that?

With fragments we can now wrap each pull request component in its own boundary. Doing this we can now also adopt a more strict approach to field errors by adapting the `throwOnFieldError` directive.

```typescript
<div className="grid gap-4">
{pullRequests.map((pr, index) => (
<PullRequestErrorBoundary key={index}>
<PullRequest pr={pr} />
</PullRequestErrorBoundary>
))}
</div>
```

Now we are handling any errors in our schema explicitly but also at a granualar level, preserving the integrity of the rest of the list.

lets throw a field error to see what that looks like.

```typescript
function injectFieldErrors(response: any): any {
if (!DEMO_FIELD_ERRORS) return response;

// Check if this is a pull request query response
const pullRequests = response?.data?.viewer?.pullRequests?.nodes;
if (!Array.isArray(pullRequests)) return response;

const errors: any[] = response.errors || [];

pullRequests.forEach((pr: any, index: number) => {
// Inject a field error for every 3rd PR's title field
errors.push({
message: `Demo error: Failed to fetch title for PR #${pr.number}`,
path: ["viewer", "pullRequests", "nodes", index, "title"],
extensions: {
code: "DEMO_ERROR",
},
});
// Set the field to null to simulate a field-level error
pr.title = null;
});

if (errors.length > 0) {
response.errors = errors;
console.log("Injected field errors:", response.errors);
}

return response;
}
```

As you can see here we are now handling an error on our first PR but the integrity of the second and the rest of the list is preserved.
2 changes: 1 addition & 1 deletion src/components/PullRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PullRequest_pr$key } from "./__generated__/PullRequest_pr.graphql";
const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => {
const data = useFragment(
graphql`
fragment PullRequest_pr on PullRequest {
fragment PullRequest_pr on PullRequest @throwOnFieldError {
id
number
title
Expand Down
55 changes: 55 additions & 0 deletions src/components/PullRequestErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Component, ReactNode } from "react";

interface Props {
children: ReactNode;
prNumber?: number;
}

interface State {
hasError: boolean;
error?: Error;
}

export class PullRequestErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("PullRequest render error:", error, errorInfo);
}

render() {
if (this.state.hasError) {
return (
<div className="block p-6 bg-white dark:bg-zinc-900 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400 mb-1">
Failed to load pull request
{this.props.prNumber && ` #${this.props.prNumber}`}
</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{this.state.error?.message || "An unexpected error occurred"}
</p>
</div>
<button
onClick={() => this.setState({ hasError: false })}
className="px-3 py-1 text-sm bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
>
Retry
</button>
</div>
</div>
);
}

return this.props.children;
}
}

7 changes: 5 additions & 2 deletions src/components/PullRequestList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { graphql, useLazyLoadQuery } from "react-relay";
import { type PullRequestListQuery } from "./__generated__/PullRequestListQuery.graphql";
import PullRequest from "./PullRequest";
import { PullRequestErrorBoundary } from "./PullRequestErrorBoundary";

const PullRequestListQuery = graphql`
query PullRequestListQuery($first: Int!) {
Expand Down Expand Up @@ -58,8 +59,10 @@ export default function PullRequestList({ count = 20 }: PullRequestListProps) {
</div>
) : (
<div className="grid gap-4">
{pullRequests.map((pr) => (
<PullRequest pr={pr} />
{pullRequests.map((pr, index) => (
<PullRequestErrorBoundary key={index}>
<PullRequest pr={pr} />
</PullRequestErrorBoundary>
))}
</div>
)}
Expand Down
43 changes: 41 additions & 2 deletions src/lib/relay/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,45 @@ import {

const HTTP_ENDPOINT = "https://api.github.com/graphql";

// Demo flag: set to true to inject fake field errors for every 3rd PR
const DEMO_FIELD_ERRORS = true;

let clientEnvironment: Environment | null = null;

// Inject fake field errors into pull request responses for demo purposes
function injectFieldErrors(response: any): any {
if (!DEMO_FIELD_ERRORS) return response;

// Check if this is a pull request query response
const pullRequests = response?.data?.viewer?.pullRequests?.nodes;
if (!Array.isArray(pullRequests) || pullRequests.length === 0)
return response;

const errors: any[] = response.errors || [];

// Only affect the first PR in the list
const firstPr = pullRequests[0];
if (firstPr) {
// Inject a field error for the first PR's title field
errors.push({
message: `Demo error: Failed to fetch title for PR #${firstPr.number}`,
path: ["viewer", "pullRequests", "nodes", 0, "title"],
extensions: {
code: "DEMO_ERROR",
},
});
// Set the field to null to simulate a field-level error
firstPr.title = null;
}

if (errors.length > 0) {
response.errors = errors;
console.log("Injected field errors:", response.errors);
}

return response;
}

export function createRelayEnvironment(accessToken: string): Environment {
const fetchFn: FetchFunction = async (request, variables) => {
const resp = await fetch(HTTP_ENDPOINT, {
Expand All @@ -25,7 +62,10 @@ export function createRelayEnvironment(accessToken: string): Environment {
}),
});

return await resp.json();
const json = await resp.json();

// Inject fake field errors for demo purposes
return injectFieldErrors(json);
};

return new Environment({
Expand All @@ -44,4 +84,3 @@ export function getRelayEnvironment(accessToken: string): Environment {
export function resetRelayEnvironment(): void {
clientEnvironment = null;
}