ReviewOS

also looking at this

craft-native/craft

fix(android): hand the launch link to the page that subscribes for it

#232
Merged glennmichael123 wants to merge fix/android-deep-link-replay into main
17 files +776 -136

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ios/src/page-script.test.tsmodified+46-5
Changes to packages/ios/src/page-script.test.ts
@@ -23,17 +23,22 @@ function pageScript(): string {
2323
2424interface Post { action: string, callbackId?: string, [key: string]: unknown }
2525
26function loadPage() {
26type Listener = (event: { type: string, detail: unknown }) => void
27
28/** `beforeInject` runs first, the way a page's own script runs before WebKit's didFinish injects the bridge. */
29function loadPage(beforeInject?: (page: Record<string, any>) => void) {
2730 const posts: Post[] = []
28 const listeners: Record<string, ((event: { type: string, detail: unknown }) => void)[]> = {}
31 const listeners: Record<string, Listener[]> = {}
2932 const page: Record<string, any> = {
3033 webkit: { messageHandlers: { craft: { postMessage: (message: Post) => posts.push(message) } } },
31 addEventListener: (type: string, listener: (event: { type: string, detail: unknown }) => void) => {
34 addEventListener: (type: string, listener: Listener) => {
3235 (listeners[type] ||= []).push(listener)
3336 },
34 removeEventListener: () => {},
37 removeEventListener: (type: string, listener: Listener) => {
38 listeners[type] = (listeners[type] || []).filter(existing => existing !== listener)
39 },
3540 dispatchEvent: (event: { type: string, detail: unknown }) => {
36 for (const listener of listeners[event.type] || []) listener(event)
41 for (const listener of [...(listeners[event.type] || [])]) listener(event)
3742 return true
3843 },
3944 location: { href: 'craft://app/' },
@@ -47,6 +52,7 @@ function loadPage() {
4752 }
4853 }
4954 const quiet = { log() {}, warn() {}, error() {} }
55 beforeInject?.(page)
5056 // eslint-disable-next-line no-new-func
5157 new Function('window', 'document', 'navigator', 'CustomEvent', 'console', pageScript())(
5258 page,
@@ -69,6 +75,9 @@ function loadPage() {
6975 // What Swift's resolveCallback and rejectCallback evaluate.
7076 answer: (action: string, value: unknown) => craft._resolveCallback(last(action).callbackId, value),
7177 refuse: (action: string, message: string, code: string) => craft._rejectCallback(last(action).callbackId, message, code),
78 // What DeepLinkManager.dispatchDeepLink evaluates, once the script has run.
79 link: (url: string, initial: boolean) =>
80 page.dispatchEvent(new CustomEvent('craftDeepLink', { detail: { url, initial } })),
7281 }
7382}
7483
@@ -144,4 +153,36 @@ describe('the injected iOS page script', () => {
144153 await expect(settled).rejects.toMatchObject({ code: 'NATIVE_CALL_FAILED' })
145154 })
146155 }
156
157 // #198 and #215. The Android page script runs the same block; its own test
158 // covers the rest of the contract.
159 const tick = () => new Promise(resolve => setTimeout(resolve, 5))
160
161 it('hands the launch link to a page that subscribes after it arrived', async () => {
162 const page = loadPage()
163 page.link('app://launch', true)
164
165 const seen: unknown[] = []
166 page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
167 await tick()
168
169 expect(seen).toEqual([{ url: 'app://launch', initial: true }])
170 })
171
172 it('delivers the launch link once to a craftReady handler that asks for it both ways', async () => {
173 // The handler runs inside the script, before native has dispatched the
174 // link at all, so there is nothing held yet for getInitialURL to claim.
175 const seen: unknown[] = []
176 const page = loadPage((window) => {
177 window.addEventListener('craftReady', () => {
178 void window.craft.deepLinks.getInitialURL()
179 window.craft.deepLinks.onLink((detail: unknown) => seen.push(detail))
180 })
181 })
182 page.link('app://launch', true)
183 await tick()
184
185 expect(seen).toEqual([])
186 expect(page.last('getInitialURL').callbackId).toMatch(/^cb_\d+$/)
187 })
147188})
packages/ios/templates/CraftApp.swiftmodified+26-14
Changes to packages/ios/templates/CraftApp.swift
@@ -2907,24 +2907,35 @@ struct CraftWebView: UIViewRepresentable {
29072907 //
29082908 // Held here instead, and handed to the first subscriber on the
29092909 // next turn, so an unsubscribe returned in the same tick still
2910 // applies. The launch link is withdrawn when the page calls
2911 // getInitialURL, its other way of receiving it, so a page that
2912 // does both in the same tick, in either order, gets it once.
2910 // applies. The launch link belongs to getInitialURL once the page
2911 // has called it, whether native has dispatched it yet or not, so a
2912 // page that does both in the same tick, in either order, gets it
2913 // once. That includes a craftReady handler, which runs before
2914 // native dispatches anything. A page that asks getInitialURL only
2915 // later, after an await, should skip `initial` in onLink.
2916 //
2917 // The same block runs on Android (#215), where this script can run
2918 // twice in one document, so its state lives on window.
29132919 (function installDeepLinkReplay(craft) {
2914 var undelivered = [];
2915 var subscribed = false;
2916 window.addEventListener('craftDeepLink', function(e) {
2917 if (!subscribed) undelivered.push(e.detail);
2918 });
2920 var replay = window.__craftDeepLinkReplay;
2921 if (!replay) {
2922 replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false};
2923 window.addEventListener('craftDeepLink', function(e) {
2924 if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail);
2925 });
2926 }
2927 function claimed(detail) {
2928 return replay.initialClaimed && detail && detail.initial;
2929 }
29192930 craft._subscribeDeepLinks = function(callback) {
29202931 var active = true;
2921 var listener = function(e) { callback(e.detail); };
2932 var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); };
29222933 window.addEventListener('craftDeepLink', listener);
2923 if (!subscribed) {
2924 subscribed = true;
2934 if (!replay.subscribed) {
2935 replay.subscribed = true;
29252936 setTimeout(function() {
2926 var pending = undelivered;
2927 undelivered = [];
2937 var pending = replay.undelivered;
2938 replay.undelivered = [];
29282939 if (!active) return;
29292940 pending.forEach(function(detail) { callback(detail); });
29302941 }, 0);
@@ -2935,7 +2946,8 @@ struct CraftWebView: UIViewRepresentable {
29352946 };
29362947 };
29372948 craft._claimInitialDeepLink = function() {
2938 undelivered = undelivered.filter(function(detail) { return !(detail && detail.initial); });
2949 replay.initialClaimed = true;
2950 replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); });
29392951 };
29402952 })(window.craft);
29412953
packages/typescript/examples/basic-app.tsmodified+3-0
Changes to packages/typescript/examples/basic-app.ts
@@ -51,6 +51,9 @@ function setupEventListeners(): void {
5151 // Deep link handling
5252 if (craft.deepLinks) {
5353 craft.deepLinks.onLink((data: DeepLinkData) => {
54 // initializeFeatures() asks getInitialURL() for the launch link after
55 // other awaits, so it would arrive here too.
56 if (data.initial) return;
5457 console.log('Deep link received:', data.url);
5558 console.log('Host:', data.host);
5659 console.log('Path:', data.path);
packages/typescript/src/api/mobile.tsmodified+10-4
Changes to packages/typescript/src/api/mobile.ts
@@ -1286,18 +1286,24 @@ export const deepLinks = {
12861286 async getInitialURL(): Promise<string | null> {
12871287 return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.())
12881288 },
1289 onLink(callback: (url: string) => void): () => void {
1289 /**
1290 * The second argument says whether this is the link that launched the app.
1291 * A page that also calls `getInitialURL()` after an await gets that link
1292 * from both, and can skip it here when `initial` is true.
1293 */
1294 onLink(callback: (url: string, link: { initial: boolean }) => void): () => void {
12901295 const craft = getCraftRoot()
12911296 if (craft?.deepLinks?.onLink) {
12921297 return craft.deepLinks.onLink((value: unknown) => {
12931298 const url = normalizeDeepLinkURL(value)
1294 if (url) callback(url)
1299 if (url) callback(url, { initial: (value as { initial?: unknown } | null)?.initial === true })
12951300 }) ?? (() => {})
12961301 }
12971302 if (typeof globalThis === 'undefined') return () => {}
12981303 const listener = (event: Event) => {
1299 const url = normalizeDeepLinkURL((event as CustomEvent<unknown>).detail)
1300 if (url) callback(url)
1304 const detail = (event as CustomEvent<unknown>).detail
1305 const url = normalizeDeepLinkURL(detail)
1306 if (url) callback(url, { initial: (detail as { initial?: unknown } | null)?.initial === true })
13011307 }
13021308 globalThis.addEventListener('craftDeepLink', listener)
13031309 return () => globalThis.removeEventListener('craftDeepLink', listener)
packages/typescript/types/craft.d.tsmodified+9-7
Changes to packages/typescript/types/craft.d.ts
@@ -833,14 +833,16 @@ export interface CraftBridge {
833833 /**
834834 * Listen for deep links.
835835 *
836 * On iOS the first subscriber is also handed, once, every link that
837 * arrived before anything was subscribed, including the link that
838 * launched the app (`initial: true`). A page that calls `getInitialURL()`
839 * before subscribing, or in the same tick, gets the launch link from there
840 * instead and not a second time here. Android does not replay yet (#215).
836 * The first subscriber is also handed, once, every link that arrived
837 * before anything was subscribed, including the link that launched the
838 * app (`initial: true`). A page that calls `getInitialURL()` before
839 * subscribing, in the same tick, or in the same `craftReady` handler, gets
840 * the launch link from there instead and not a second time here. A page
841 * that calls `getInitialURL()` only later, after an await, gets it from
842 * both, and should skip `data.initial` here.
841843 *
842844 * @param callback - Called when a deep link is received
843 * @returns On iOS, a function that unsubscribes
845 * @returns A function that unsubscribes, on iOS and Android
844846 */
845847 onLink(callback: (data: DeepLinkData) => void): (() => void) | void;
846848 };
@@ -1025,7 +1027,7 @@ export interface DeepLinkData {
10251027 query: string;
10261028 /** Parsed query parameters */
10271029 queryParams?: Record<string, string>;
1028 /** True for the link that launched the app (iOS) */
1030 /** True for the link that launched the app */
10291031 initial?: boolean;
10301032}
10311033