summaryrefslogtreecommitdiff
path: root/shared/apps-common/src/jet/prefetched-intents/index.ts
blob: dd5d393645538492b3e647f84fdc35a6a320ee00 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import type { LoggerFactory } from '@amp/web-apps-logger';
import type { Intent, IntentReturnType } from '@jet/environment/dispatching';
import { serializeServerData, stableStringify } from './server-data';
import type { PrefetchedIntent } from './types';
import { getPrefetchedIntents } from './get-prefetched-intents';

export type { PrefetchedIntent } from './types';

export function serializePrefetchedIntents(
    loggerFactory: LoggerFactory,
    prefetchedIntents: PrefetchedIntent[],
): string {
    const serialized = serializeServerData(
        prefetchedIntents.map(removeSeoData),
    );

    if (serialized.length === 0) {
        const logger = loggerFactory.loggerFor('serializePrefetchedIntents');
        logger.warn('failed to serialize prefetched intents');
    }

    return serialized;
}

// SEO data is never needed for the first clientside render since the server
// already adds SEO tags. The seoData convention is ubiquitous across the apps.
// See: rdar://144581413 (Etag constantly changes on pages with songs due to seoData.ogSongs)
function removeSeoData(intent: PrefetchedIntent): PrefetchedIntent {
    const { data } = intent;

    // We very intentionally return the original intent to prevent
    // needlessly allocating new objects.

    if (data === null || typeof data !== 'object' || !('seoData' in data)) {
        return intent;
    }

    const { seoData } = data;
    if (seoData === null || typeof seoData !== 'object') {
        return intent;
    }

    let partialSeoData:
        | { pageTitle?: unknown; titleHeader?: unknown }
        | undefined = undefined;
    if ('pageTitle' in seoData || 'titleHeader' in seoData) {
        partialSeoData = {};

        if ('pageTitle' in seoData) {
            partialSeoData['pageTitle'] = seoData.pageTitle;
        }

        if ('titleHeader' in seoData) {
            partialSeoData['titleHeader'] = seoData.titleHeader;
        }
    }

    // Only if we're actually going to do the removal do we spread
    return {
        ...intent,
        data: {
            ...data,
            // Page title is desirable to keep as it is occasionally consulted
            // outside of MetaTags.svelte
            seoData: partialSeoData,
        },
    };
}

export class PrefetchedIntents {
    static empty(): PrefetchedIntents {
        return new PrefetchedIntents(new Map());
    }

    static fromDom(
        loggerFactory: LoggerFactory,
        options?: { evenIfSignedIn?: boolean; featureKitItfe?: string },
    ): PrefetchedIntents {
        return new PrefetchedIntents(
            getPrefetchedIntents(loggerFactory, options),
        );
    }

    private intents: Map<string, unknown>;

    private constructor(intents: Map<string, unknown>) {
        this.intents = intents;
    }

    get<I extends Intent<unknown>>(intent: I): IntentReturnType<I> | undefined {
        if (this.intents.size === 0) {
            return;
        }

        let subject: string | void;
        try {
            subject = stableStringify(intent);
        } catch (e) {
            // It's possible the intents don't stringify. If that's that case,
            // then we won't find it in this.intents, since the keys of that
            // are successfully stringified intents. We could try something
            // sophisticated here, but it's probably not worth it as most
            // intents will serialize.
            return;
        }

        const data = this.intents.get(subject);

        // Remove the prefetched data so that it can only be used once
        this.intents.delete(subject);

        // NOTE: There really isn't a good way to be safe with types here. We
        // don't have a type guard for arbitrary IntentReturnType<I>. We just
        // have to trust that the serialized data is of the correct type. This
        // isn't unreasonable since we control serialization.
        return data as unknown as IntentReturnType<I> | undefined;
    }
}