Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fe671d7
Fix sign-in race in Playwright global setup (FE-1171)
claude Jul 9, 2026
dddd988
Allow time for type counts to load in types-page Playwright spec (FE-…
claude Jul 9, 2026
6d1100f
Make link target entity types honest and guard consumers (FE-1172)
claude Jul 9, 2026
fc64a12
Throw invariant errors instead of silently skipping missing public en…
claude Jul 10, 2026
192c5d3
Speed up /types page counts with a latest-only query instead of raisi…
claude Jul 10, 2026
cda0c34
Address review: timeless error messages and bind checked entities to …
claude Jul 10, 2026
2203c6b
Merge branch 'main' into claude/fe-1171-fe-1172-fe-1174-flaky-playwright
TimDiekmann Jul 10, 2026
79e6816
Merge branch 'main' into claude/fe-1171-fe-1172-fe-1174-flaky-playwright
claude Jul 16, 2026
2ae56ff
Address review feedback on link handling and error surfacing
claude Jul 16, 2026
64ee843
Handle possibly-absent link target in dashboard item resolution
claude Jul 16, 2026
a1a48d7
Only register link entity type ids for links that produce a row
claude Jul 16, 2026
53dcc36
Bind presence-checked link and target revisions to variables
claude Jul 16, 2026
e4cb433
Base create-if-absent decisions on link presence, not resolved targets
claude Jul 16, 2026
a5b20ea
Treat missing has-parent link targets as parentless pages
claude Jul 16, 2026
5764053
Recover by recreating browser settings when the linked settings entit…
claude Jul 16, 2026
3e7942f
bind the existing query pair once instead of repeated index access
claude Jul 16, 2026
7240cc3
Log errors behind the plugin sign-out fallback, drop required-rightEn…
claude Jul 16, 2026
275ecbf
Fail the save loudly when a content link's block entity is unresolved…
claude Jul 16, 2026
b91ae5c
Skip unresolvable content links when fetching the collection for save…
claude Jul 16, 2026
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
5 changes: 5 additions & 0 deletions .changeset/fe-1172-optional-link-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@blockprotocol/graph": minor
Comment thread
TimDiekmann marked this conversation as resolved.
---

Make `rightEntity` / `leftEntity` on `LinkEntityAndRightEntity` / `LinkEntityAndLeftEntity` possibly `undefined`, reflecting what `getOutgoingLinkAndTargetEntities` / `getIncomingLinkAndSourceEntities` actually return when a link's `has-right-entity` / `has-left-entity` edge is not resolved into the subgraph. Previously an unsafe cast hid this, allowing runtime crashes when consumers indexed into a missing target entity array.
7 changes: 4 additions & 3 deletions apps/hash-api/src/graphql/resolvers/knowledge/org/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ export const getPendingOrgInvitationsFromSubgraph = async (
LinkEntityAndLeftEntity<HashEntity, HashLinkEntity>[]
>(subgraph, root.entityId);

const linkEntity = linkAndOrgEntities[0]?.linkEntity[0];
const [linkAndOrgEntity] = linkAndOrgEntities;
const linkEntity = linkAndOrgEntity?.linkEntity[0];

if (!linkEntity) {
if (!linkAndOrgEntity || !linkEntity) {
throw new Error(
`Pending invitation with entityId ${root.entityId} has no incoming link.`,
);
Expand All @@ -82,7 +83,7 @@ export const getPendingOrgInvitationsFromSubgraph = async (
);
}

const orgEntity = linkAndOrgEntities[0]?.leftEntity[0];
const orgEntity = linkAndOrgEntity.leftEntity?.[0];

if (!orgEntity) {
throw new Error(
Expand Down
20 changes: 15 additions & 5 deletions apps/hash-frontend/src/components/hooks/use-account-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,23 @@ export const useAccountPages = (
latestPage.metadata.recordId.entityId,
);

const parentLink = pageOutgoingLinks.find(({ linkEntity }) =>
linkEntity[0]!.metadata.entityTypeIds.includes(
systemLinkEntityTypes.hasParent.linkEntityTypeId,
),
const [parentLink] = pageOutgoingLinks.flatMap(
({ linkEntity, rightEntity }) =>
linkEntity[0]?.metadata.entityTypeIds.includes(
systemLinkEntityTypes.hasParent.linkEntityTypeId,
)
? [{ rightEntity }]
: [],
);

const parentPage = parentLink?.rightEntity[0] ?? null;
/**
* A `has-parent` link can be present in the subgraph while its target
* page is legitimately absent (`rightEntity` missing or empty): the
* parent page may be archived (its revisions no longer overlap the
* queried interval) or not visible to the requester. Treat such pages
* as parentless rather than erroring.
*/
const parentPage = parentLink?.rightEntity?.[0] ?? null;
Comment thread
cursor[bot] marked this conversation as resolved.

return {
...simplifyProperties(latestPage.properties as PageProperties),
Expand Down
66 changes: 39 additions & 27 deletions apps/hash-frontend/src/lib/user-and-org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ export const constructOrg = (params: {
continue;
}

const rightEntityRevision = rightEntity[0];
/**
* The right entity may be missing from the subgraph (e.g. if it was not
* resolved when the subgraph was produced) – skip the link in that case.
*/
const rightEntityRevision = rightEntity?.[0];

if (!rightEntityRevision) {
continue;
Expand Down Expand Up @@ -269,11 +273,17 @@ export const constructOrg = (params: {
continue;
}

const userEntityRevision = leftEntity[0];
const userEntityRevision = leftEntity?.[0];

if (!userEntityRevision) {
/**
* User entities are public – if the membership link is in the subgraph,
* its left (user) entity must be too. A missing user entity means the
* subgraph is internally inconsistent, which we surface loudly rather
* than silently dropping the membership.
*/
throw new Error(
`Failed to find the current user entity associated with the membership with entity ID: ${linkEntityRevision.metadata.recordId.entityId}`,
`Invariant violation: membership link ${linkEntityRevision.metadata.recordId.entityId} is missing its left (user) entity in the subgraph`,
);
}

Expand Down Expand Up @@ -450,14 +460,20 @@ export const constructUser = (params: {
intervalForTimestamp(currentTimestamp()),
);

const avatarLinkAndEntities: LinkEntityAndRightEntity[] = [];
const coverImageLinkAndEntities: LinkEntityAndRightEntity[] = [];
const hasBioLinkAndEntities: LinkEntityAndRightEntity[] = [];
/**
* These hold the latest revision of each link and its target entity, taken
* from the revision arrays after checking that both are present.
*/
type LinkAndTargetRevision = { linkEntity: LinkEntity; rightEntity: Entity };

const avatarLinkAndEntities: LinkAndTargetRevision[] = [];
const coverImageLinkAndEntities: LinkAndTargetRevision[] = [];
const hasBioLinkAndEntities: LinkAndTargetRevision[] = [];
const hasServiceAccounts: User["hasServiceAccounts"] = [];

for (const linkAndEntity of outgoingLinkAndTargetEntities) {
const linkEntity = linkAndEntity.linkEntity[0];
const rightEntity = linkAndEntity.rightEntity[0];
const rightEntity = linkAndEntity.rightEntity?.[0];

if (!linkEntity || !rightEntity) {
continue;
Expand All @@ -468,7 +484,7 @@ export const constructUser = (params: {
if (
entityTypeIds.includes(systemLinkEntityTypes.hasAvatar.linkEntityTypeId)
) {
avatarLinkAndEntities.push(linkAndEntity);
avatarLinkAndEntities.push({ linkEntity, rightEntity });
continue;
}

Expand All @@ -477,12 +493,12 @@ export const constructUser = (params: {
systemLinkEntityTypes.hasCoverImage.linkEntityTypeId,
)
) {
coverImageLinkAndEntities.push(linkAndEntity);
coverImageLinkAndEntities.push({ linkEntity, rightEntity });
continue;
}

if (entityTypeIds.includes(systemLinkEntityTypes.hasBio.linkEntityTypeId)) {
hasBioLinkAndEntities.push(linkAndEntity);
hasBioLinkAndEntities.push({ linkEntity, rightEntity });
continue;
}

Expand All @@ -491,7 +507,7 @@ export const constructUser = (params: {
systemLinkEntityTypes.hasServiceAccount.linkEntityTypeId,
)
) {
const serviceAccountEntity = linkAndEntity.rightEntity[0]!;
const serviceAccountEntity = rightEntity;

const { profileUrl } = simplifyProperties(
serviceAccountEntity.properties as ServiceAccount["properties"],
Expand All @@ -510,31 +526,27 @@ export const constructUser = (params: {
}
}

const hasAvatar = avatarLinkAndEntities[0]
const [avatarLinkAndEntity] = avatarLinkAndEntities;
const hasAvatar = avatarLinkAndEntity
? {
// these are each arrays because each entity can have multiple revisions
linkEntity: avatarLinkAndEntities[0].linkEntity[0]!,
imageEntity: avatarLinkAndEntities[0]
.rightEntity[0]! as Entity<ImageFile>,
linkEntity: avatarLinkAndEntity.linkEntity,
imageEntity: avatarLinkAndEntity.rightEntity as Entity<ImageFile>,
}
: undefined;

const hasCoverImage = coverImageLinkAndEntities[0]
const [coverImageLinkAndEntity] = coverImageLinkAndEntities;
const hasCoverImage = coverImageLinkAndEntity
? {
// these are each arrays because each entity can have multiple revisions
linkEntity: coverImageLinkAndEntities[0].linkEntity[0]!,
imageEntity: coverImageLinkAndEntities[0]
.rightEntity[0]! as Entity<ImageFile>,
linkEntity: coverImageLinkAndEntity.linkEntity,
imageEntity: coverImageLinkAndEntity.rightEntity as Entity<ImageFile>,
}
: undefined;

const hasBio = hasBioLinkAndEntities[0]
const [hasBioLinkAndEntity] = hasBioLinkAndEntities;
const hasBio = hasBioLinkAndEntity
? {
// these are each arrays because each entity can have multiple revisions
linkEntity: hasBioLinkAndEntities[0]
.linkEntity[0]! as LinkEntity<HasBio>,
profileBioEntity: hasBioLinkAndEntities[0]
.rightEntity[0]! as Entity<ProfileBio>,
linkEntity: hasBioLinkAndEntity.linkEntity as LinkEntity<HasBio>,
profileBioEntity: hasBioLinkAndEntity.rightEntity as Entity<ProfileBio>,
}
: undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ const DashboardPage: NextPageWithLayout = () => {
continue;
}

const itemEntity = rightEntity[0] as
const itemEntity = rightEntity?.[0] as
| HashEntity<DashboardItemEntity>
| undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,35 +221,53 @@ export const useNotificationsWithLinksContextValue =
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.occurredInEntity.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

const occurredInBlock = outgoingLinks.find(
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.occurredInBlock.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

const occurredInText = outgoingLinks.find(
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.occurredInText.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

const triggeredByUserEntity = outgoingLinks.find(
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.triggeredByUser.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];
Comment thread
cursor[bot] marked this conversation as resolved.

if (
!occurredInEntity ||
!occurredInBlock ||
!occurredInText ||
!triggeredByUserEntity
) {
throw new Error(
`Mention notification "${entityId}" is missing required links`,
/**
* The linked entities may be missing from the subgraph, e.g.
* because the user no longer has permission to view them –
* skip the notification rather than failing the whole list.
*/
const missingLinks = Object.entries({
occurredInEntity,
occurredInBlock,
occurredInText,
triggeredByUser: triggeredByUserEntity,
})
.filter(([, linkedEntity]) => !linkedEntity)
.map(([linkName]) => linkName)
.join(", ");

// eslint-disable-next-line no-console -- TODO: consider using logger
console.warn(
`Skipping mention notification ${entityId} because the target of the following link(s) could not be resolved: ${missingLinks}`,
);

return null;
Comment thread
cursor[bot] marked this conversation as resolved.
}

const triggeredByUser = constructMinimalUser({
Expand All @@ -260,7 +278,7 @@ export const useNotificationsWithLinksContextValue =
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.occurredInComment.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

if (occurredInComment) {
return {
Expand Down Expand Up @@ -297,35 +315,53 @@ export const useNotificationsWithLinksContextValue =
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.occurredInEntity.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

const occurredInBlock = outgoingLinks.find(
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.occurredInBlock.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

const triggeredByComment = outgoingLinks.find(
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.triggeredByComment.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

const triggeredByUserEntity = outgoingLinks.find(
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.triggeredByUser.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

if (
!occurredInEntity ||
!occurredInBlock ||
!triggeredByComment ||
!triggeredByUserEntity
) {
throw new Error(
`Comment notification "${entityId}" is missing required links`,
/**
* The linked entities may be missing from the subgraph, e.g.
* because the user no longer has permission to view them –
* skip the notification rather than failing the whole list.
*/
const missingLinks = Object.entries({
occurredInEntity,
occurredInBlock,
triggeredByComment,
triggeredByUser: triggeredByUserEntity,
})
.filter(([, linkedEntity]) => !linkedEntity)
.map(([linkName]) => linkName)
.join(", ");

// eslint-disable-next-line no-console -- TODO: consider using logger
console.warn(
`Skipping comment notification ${entityId} because the target of the following link(s) could not be resolved: ${missingLinks}`,
);

return null;
}

const triggeredByUser = constructMinimalUser({
Expand All @@ -336,7 +372,7 @@ export const useNotificationsWithLinksContextValue =
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.repliedToComment.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

if (repliedToComment) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,19 @@ export const useLinearIntegrations = (): {
})
.map((linkAndTarget) => {
const linkEntity = linkAndTarget.linkEntity[0]!;
const rightEntity = linkAndTarget.rightEntity[0]!;
const rightEntity = linkAndTarget.rightEntity?.[0];

if (!rightEntity) {
/**
* The target of a `syncLinearDataWith` link is a user or org
* (web) entity, which is public – if the link is in the
* subgraph its target must be too. A missing target means the
* subgraph is internally inconsistent.
*/
throw new Error(
`Invariant violation: syncLinearDataWith link ${linkEntity.metadata.recordId.entityId} is missing its right (web) entity in the subgraph`,
);
}

const { linearTeamId: linearTeamIds } = simplifyProperties(
linkEntity.properties as SyncLinearDataWithProperties,
Expand Down
Loading
Loading