Invite: fix stale props in inviteAccept callback

Now that the store is immutable, callbacks that close over props will
see stale props because the reference is captured at instantiation. This
was not an issue previously, because the references would be directly
mutated. To remedy this, useRunIO has been added, which takes an async
function to run for the actual network processing, and a second
callback, which is able to see fresh props because it is not
instantiated until the async callback has resolved successfully.
Importantly, useRunIO does not resolve until the second callback has
finished, preserving the semantics of the callback for Formik handlers
and the like.

Fixes urbit/landscape#691
This commit is contained in:
Liam Fitzgerald 2021-04-06 14:13:40 +10:00
parent 264fce214b
commit 6da3877430
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
2 changed files with 151 additions and 77 deletions

View File

@ -0,0 +1,52 @@
import { useState, useEffect } from "react";
import { useWaitForProps } from "./useWaitForProps";
import {unstable_batchedUpdates} from "react-dom";
export type IOInstance<I, P, O> = (
input: I
) => (props: P) => Promise<[(p: P) => boolean, O]>;
export function useRunIO<I, O>(
io: (i: I) => Promise<O>,
after: (o: O) => void,
key: string
) {
const [resolve, setResolve] = useState<() => void>(() => () => {});
const [reject, setReject] = useState<(e: any) => void>(() => () => {});
const [output, setOutput] = useState<O | null>(null);
const [done, setDone] = useState(false);
const run = (i: I) =>
new Promise((res, rej) => {
setResolve(() => res);
setReject(() => rej);
io(i)
.then((o) => {
unstable_batchedUpdates(() => {
setOutput(o);
setDone(true);
});
})
.catch(rej);
});
useEffect(() => {
reject(new Error("useRunIO: key changed"));
setDone(false);
setOutput(null);
}, [key]);
useEffect(() => {
if (!done) {
return;
}
try {
after(output!);
resolve();
} catch (e) {
reject(e);
}
}, [done]);
return run;
}

View File

@ -1,27 +1,28 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import React, { useState, useEffect, useCallback } from "react";
import { useHistory } from "react-router-dom";
import {
MetadataUpdatePreview,
Contacts,
JoinRequests,
Groups,
Associations
} from '@urbit/api';
import { Invite } from '@urbit/api/invite';
import { Text, Icon, Row } from '@tlon/indigo-react';
Associations,
} from "@urbit/api";
import { Invite } from "@urbit/api/invite";
import { Text, Icon, Row } from "@tlon/indigo-react";
import { cite, useShowNickname } from '~/logic/lib/util';
import GlobalApi from '~/logic/api/global';
import { resourceFromPath } from '~/logic/lib/group';
import { GroupInvite } from './Group';
import { InviteSkeleton } from './InviteSkeleton';
import { JoinSkeleton } from './JoinSkeleton';
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import useGroupState from '~/logic/state/group';
import useContactState from '~/logic/state/contact';
import useMetadataState from '~/logic/state/metadata';
import useGraphState from '~/logic/state/graph';
import { cite, useShowNickname } from "~/logic/lib/util";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath } from "~/logic/lib/group";
import { GroupInvite } from "./Group";
import { InviteSkeleton } from "./InviteSkeleton";
import { JoinSkeleton } from "./JoinSkeleton";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import useGroupState from "~/logic/state/group";
import useContactState from "~/logic/state/contact";
import useMetadataState from "~/logic/state/metadata";
import useGraphState from "~/logic/state/graph";
import { useRunIO } from "~/logic/lib/useRunIO";
interface InviteItemProps {
invite?: Invite;
@ -32,62 +33,81 @@ interface InviteItemProps {
api: GlobalApi;
}
export function useInviteAccept(
resource: string,
api: GlobalApi,
app?: string,
uid?: string,
invite?: Invite,
) {
const { ship, name } = resourceFromPath(resource);
const history = useHistory();
const associations = useMetadataState((s) => s.associations);
const groups = useGroupState((s) => s.groups);
const graphKeys = useGraphState((s) => s.graphKeys);
const waiter = useWaitForProps({ associations, graphKeys, groups });
return useRunIO<void, boolean>(
async () => {
if (!(app && invite && uid)) {
return false;
}
if (resource in groups) {
await api.invite.decline(app, uid);
return false;
}
await api.groups.join(ship, name);
await api.invite.accept(app, uid);
await waiter((p) => {
return (
(resource in p.groups &&
resource in (p.associations?.graph ?? {}) &&
p.graphKeys.has(resource.slice(7))) ||
resource in (p.associations?.groups ?? {})
);
});
return true;
},
(success: boolean) => {
if (!success) {
return;
}
if (groups?.[resource]?.hidden) {
const { metadata } = associations.graph[resource];
if (metadata && "graph" in metadata.config) {
if (metadata.config.graph === "chat") {
history.push(
`/~landscape/messages/resource/${metadata.config.graph}${resource}`
);
} else {
history.push(
`/~landscape/home/resource/${metadata.config.graph}${resource}`
);
}
} else {
console.error("unknown metadata: ", metadata);
}
} else {
history.push(`/~landscape${resource}`);
}
},
resource
);
}
export function InviteItem(props: InviteItemProps) {
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const { pendingJoin, invite, resource, uid, app, api } = props;
const { ship, name } = resourceFromPath(resource);
const groups = useGroupState(state => state.groups);
const graphKeys = useGraphState(s => s.graphKeys);
const associations = useMetadataState(state => state.associations);
const contacts = useContactState(state => state.contacts);
const { name } = resourceFromPath(resource);
const contacts = useContactState((state) => state.contacts);
const contact = contacts?.[`~${invite?.ship}`] ?? {};
const showNickname = useShowNickname(contact);
const waiter = useWaitForProps(
{ associations, groups, pendingJoin, graphKeys: Array.from(graphKeys) },
50000
);
const history = useHistory();
const inviteAccept = useCallback(async () => {
if (!(app && invite && uid)) {
return;
}
if(resource in groups) {
await api.invite.decline(app, uid);
return;
}
api.groups.join(ship, name);
await waiter(p => !!p.pendingJoin);
api.invite.accept(app, uid);
await waiter((p) => {
return (
resource in p.groups &&
(resource in (p.associations?.graph ?? {}) ||
resource in (p.associations?.groups ?? {}))
);
});
if (groups?.[resource]?.hidden) {
await waiter(p => p.graphKeys.includes(resource.slice(7)));
const { metadata } = associations.graph[resource];
if (metadata && 'graph' in metadata.config) {
if (metadata.config.graph === 'chat') {
history.push(`/~landscape/messages/resource/${metadata.config.graph}${resource}`);
} else {
history.push(`/~landscape/home/resource/${metadata.config.graph}${resource}`);
}
} else {
console.error('unknown metadata: ', metadata);
}
} else {
history.push(`/~landscape${resource}`);
}
}, [app, history, waiter, invite, uid, resource, groups, associations]);
const inviteAccept = useInviteAccept(resource, api, app, uid, invite);
const inviteDecline = useCallback(async () => {
if(!(app && uid)) {
if (!(app && uid)) {
return;
}
await api.invite.decline(app, uid);
@ -96,7 +116,7 @@ export function InviteItem(props: InviteItemProps) {
const handlers = { onAccept: inviteAccept, onDecline: inviteDecline };
useEffect(() => {
if (!app || app === 'groups') {
if (!app || app === "groups") {
(async () => {
setPreview(await api.metadata.preview(resource));
})();
@ -108,7 +128,7 @@ export function InviteItem(props: InviteItemProps) {
}
}, [invite]);
if(pendingJoin?.hidden) {
if (pendingJoin?.hidden) {
return null;
}
@ -123,7 +143,7 @@ export function InviteItem(props: InviteItemProps) {
{...handlers}
/>
);
} else if (invite && name.startsWith('dm--')) {
} else if (invite && name.startsWith("dm--")) {
return (
<InviteSkeleton
gapY="3"
@ -133,16 +153,18 @@ export function InviteItem(props: InviteItemProps) {
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1"
<Text
mr="1"
mono={!showNickname}
fontWeight={showNickname ? '500' : '400'}>
fontWeight={showNickname ? "500" : "400"}
>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">invited you to a DM</Text>
</Row>
</InviteSkeleton>
);
} else if (status && name.startsWith('dm--')) {
} else if (status && name.startsWith("dm--")) {
return (
<JoinSkeleton api={api} resource={resource} status={status} gapY="3">
<Row py="1" alignItems="center">
@ -162,9 +184,11 @@ export function InviteItem(props: InviteItemProps) {
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1"
<Text
mr="1"
mono={!showNickname}
fontWeight={showNickname ? '500' : '400'}>
fontWeight={showNickname ? "500" : "400"}
>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">
@ -174,14 +198,12 @@ export function InviteItem(props: InviteItemProps) {
</InviteSkeleton>
);
} else if (pendingJoin) {
const [, , ship, name] = resource.split('/');
const [, , ship, name] = resource.split("/");
return (
<JoinSkeleton api={api} resource={resource} status={pendingJoin}>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">
You are joining
</Text>
<Text mr="1">You are joining</Text>
<Text mono>
{cite(ship)}/{name}
</Text>