Skip to content

Commit bc524e9

Browse files
committed
Refactor EditableHeaders to think primarily in terms of _raw_ headers
This does make some small behavioural changes, largely improving the header editing experience e.g. when entering duplicate headers in some places (in the past, I've seen issues where they were reordered unexpectedly live as you typed - now that should never happen). This sets the groundwork for a) more raw headers everywhere in future, and b) raw header editing for Send support soon.
1 parent 517a2f8 commit bc524e9

File tree

10 files changed

+441
-208
lines changed

10 files changed

+441
-208
lines changed
Lines changed: 122 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,79 @@
11
import * as _ from 'lodash';
22
import * as React from 'react';
3-
import { observer } from 'mobx-react';
3+
import { action, observable, reaction, runInAction } from 'mobx';
4+
import { disposeOnUnmount, observer } from 'mobx-react';
45

5-
import { Headers } from '../../types';
6+
import { RawHeaders } from '../../types';
67
import { HEADER_NAME_PATTERN } from '../../util/headers';
78

8-
import { EditablePairs, Pair, PairsArray } from './editable-pairs';
9+
import { EditablePairs, PairsArray } from './editable-pairs';
910

10-
export type HeadersArray = Array<Pick<Pair, 'key' | 'value'>>;
11+
const tupleArrayToPairsArray = (headers: RawHeaders): PairsArray =>
12+
headers.map(([key, value]) => ({ key, value }));
1113

12-
const headersToHeadersArray = (headers: Headers): HeadersArray =>
13-
Object.entries(headers || {}).reduce(
14-
(acc: HeadersArray, [key, value]) => {
15-
if (_.isArray(value)) {
16-
acc = acc.concat(value.map(value => ({ key, value })))
17-
} else {
18-
acc.push({ key, value: value || '' });
19-
}
20-
return acc;
21-
}, []
22-
);
23-
24-
const headersArrayToHeaders = (headers: HeadersArray): Headers =>
25-
headers.reduce((headersObj: { [k: string]: string | string[] }, { key, value }) => {
26-
const headerName = key.toLowerCase();
27-
28-
const existingValue = headersObj[headerName];
29-
if (existingValue === undefined) {
30-
headersObj[headerName] = value;
31-
} else if (_.isString(existingValue)) {
32-
headersObj[headerName] = [existingValue, value];
33-
} else {
34-
existingValue.push(value);
35-
}
36-
return headersObj;
37-
}, {});
38-
39-
const withH2HeadersDisabled = (headers: HeadersArray): PairsArray =>
14+
const withH2HeadersDisabled = (headers: PairsArray): PairsArray =>
4015
headers.map(({ key, value }) =>
4116
key.startsWith(':')
4217
? { key, value, disabled: true }
4318
: { key, value }
4419
);
4520

46-
const normalizeHeaderInput = (headers: PairsArray): HeadersArray =>
21+
const rawHeadersAsEditablePairs = (rawHeaders: RawHeaders) => {
22+
return withH2HeadersDisabled(tupleArrayToPairsArray(rawHeaders));
23+
};
24+
25+
const outputToRawHeaders = (output: PairsArray): RawHeaders =>
26+
output.map(({ key, value }) => [key, value]);
27+
28+
const stripPseudoHeaders = (headers: PairsArray): PairsArray =>
29+
// Strip leading colons - HTTP/2 headers should never be entered raw (but don't lower case)
30+
headers.map(({ key, value, disabled }) => ({
31+
key: !disabled && key.startsWith(':')
32+
? key.slice(1)
33+
: key,
34+
value,
35+
disabled
36+
}));
37+
38+
const stripPseudoHeadersAndLowercase = (headers: PairsArray): PairsArray =>
4739
// Lowercase header keys, and strip any leading colons - HTTP/2 headers should never be entered raw
4840
headers.map(({ key, value, disabled }) => ({
4941
key: !disabled && key.startsWith(':')
5042
? key.slice(1).toLowerCase()
5143
: key.toLowerCase(),
52-
value
44+
value,
45+
disabled
5346
}));
5447

55-
interface EditableHeadersProps<R = Headers> {
56-
headers: Headers;
57-
onChange: (headers: R) => void;
58-
convertResult?: (headers: Headers) => R;
48+
interface EditableRawHeadersProps {
49+
input: RawHeaders;
50+
onChange: (headers: RawHeaders) => void;
5951

6052
// It's unclear whether you're strictly allowed completely empty header values, but it's definitely
6153
// not recommended and poorly supported. By default we disable it except for special use cases.
6254
allowEmptyValues?: boolean;
55+
56+
// By default, we lowercase headers. This isn't strictly required, but it's generally clearer,
57+
// simpler, and semantically meaningless. HTTP/2 is actually even forced lowercase on the wire.
58+
// Nonetheless, sometimes (breakpoints) you want to ignore that and edit with case regardless
59+
// because the real raw headers have existing cases and otherwise it's weird:
60+
preserveKeyCase?: boolean;
6361
}
6462

65-
export const EditableHeaders = observer(<R extends unknown>(props: EditableHeadersProps<R>) => {
66-
const { headers, onChange, allowEmptyValues, convertResult } = props;
63+
export const EditableRawHeaders = observer((
64+
props: EditableRawHeadersProps
65+
) => {
66+
const { input: headers, onChange, allowEmptyValues, preserveKeyCase } = props;
6767

68-
return <EditablePairs<R>
69-
pairs={withH2HeadersDisabled(headersToHeadersArray(headers))}
68+
return <EditablePairs<RawHeaders>
69+
pairs={rawHeadersAsEditablePairs(headers)}
7070
onChange={onChange}
71-
transformInput={normalizeHeaderInput}
72-
convertResult={(pairs: PairsArray) =>
73-
convertResult
74-
? convertResult(headersArrayToHeaders(pairs))
75-
: headersArrayToHeaders(pairs) as unknown as R
71+
transformInput={
72+
preserveKeyCase
73+
? stripPseudoHeaders
74+
: stripPseudoHeadersAndLowercase
7675
}
76+
convertResult={outputToRawHeaders}
7777

7878
allowEmptyValues={allowEmptyValues}
7979

@@ -83,4 +83,78 @@ export const EditableHeaders = observer(<R extends unknown>(props: EditableHeade
8383
keyPlaceholder='Header name'
8484
valuePlaceholder='Header value'
8585
/>;
86-
});
86+
});
87+
88+
interface EditableHeadersProps<T> {
89+
input: T,
90+
91+
convertInput: (input: T) => RawHeaders,
92+
convertResult: (headers: RawHeaders) => T,
93+
94+
onChange: (headers: T) => void;
95+
onInvalidState?: () => void;
96+
97+
// It's unclear whether you're strictly allowed completely empty header values, but it's definitely
98+
// not recommended and poorly supported. By default we disable it except for special use cases.
99+
allowEmptyValues?: boolean;
100+
}
101+
102+
// Editable headers acts as a wrapper around the raw header pair modification, converting to and from other
103+
// formats (most commonly header objects, rather than arrays) whilst avoiding unnecessary updates that
104+
// cause churn in the UI due to unrepresentable states in that format (e.g. duplicate headers that aren't
105+
// directly next to each other). This allows you to edit as if the data was in raw header format, but
106+
// get different data live as it changes, without collapsing to that state until later.
107+
@observer
108+
export class EditableHeaders<T> extends React.Component<EditableHeadersProps<T>> {
109+
110+
@observable
111+
private rawHeaders: RawHeaders = this.props.convertInput(this.props.input);
112+
113+
private output: T = this.props.input;
114+
115+
componentDidMount() {
116+
// Watch the input, but only update our state if its materially different
117+
// to the last output we returned.
118+
disposeOnUnmount(this, reaction(
119+
() => this.props.input,
120+
(input) => {
121+
if (!_.isEqual(input, this.output)) {
122+
const newInput = this.props.convertInput(input);
123+
runInAction(() => {
124+
this.rawHeaders = newInput;
125+
});
126+
}
127+
}
128+
));
129+
}
130+
131+
@action.bound
132+
onChangeRawHeaders(rawHeaders: RawHeaders) {
133+
this.rawHeaders = rawHeaders;
134+
135+
const { allowEmptyValues, convertResult, onChange, onInvalidState } = this.props;
136+
137+
if (allowEmptyValues) {
138+
this.output = convertResult(rawHeaders);
139+
onChange(this.output);
140+
} else {
141+
if (rawHeaders.some((([_, value]) => !value))) return onInvalidState?.();
142+
if (rawHeaders.some(([key]) => !key)) return onInvalidState?.();
143+
144+
this.output = convertResult(rawHeaders);
145+
onChange(this.output);
146+
}
147+
}
148+
149+
render() {
150+
const { allowEmptyValues } = this.props;
151+
const { rawHeaders, onChangeRawHeaders } = this;
152+
153+
return <EditableRawHeaders
154+
input={rawHeaders}
155+
onChange={onChangeRawHeaders}
156+
allowEmptyValues={allowEmptyValues}
157+
/>;
158+
}
159+
160+
}

0 commit comments

Comments
 (0)