Fix bug in usePush, also some bugs in install state
Test / test (push) Failing after 24s Details

This commit is contained in:
Jeff 2024-09-25 01:23:39 -04:00
parent e2cb0eb214
commit e09769e1ba
8 changed files with 252 additions and 128 deletions

19
app/ClientOnly.tsx Normal file
View File

@ -0,0 +1,19 @@
import { ReactNode, useEffect, useState } from "react"
export default function ClientOnly({
fallback,
children,
}: {
fallback: ReactNode
children(): ReactNode
}) {
const isClient = typeof window !== "undefined"
const [rendered, setRendered] = useState(false)
useEffect(() => {
setRendered(true)
}, [])
return isClient && rendered ? children() : fallback
}

View File

@ -1,6 +1,6 @@
import React from "react" import React from "react"
import { useEffect } from "react" import { useEffect } from "react"
import { usePush } from "remix-pwa-monorepo/packages/push/client/hook" import { usePush } from "../usePush"
export default function EnableNotifications({ export default function EnableNotifications({
onSubscribe, onSubscribe,

View File

@ -10,6 +10,7 @@ import versionAtLeast from "semver/functions/gte"
import UAParser from "ua-parser-js" import UAParser from "ua-parser-js"
import InstallPrompts from "../install/InstallPrompts" import InstallPrompts from "../install/InstallPrompts"
import useInstallState from "../useInstallState" import useInstallState from "../useInstallState"
import ClientOnly from "../ClientOnly"
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [ return [
@ -22,16 +23,24 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const userAgent = request.headers.get("user-agent") const userAgent = request.headers.get("user-agent")
const parsedUserAgent = new UAParser(userAgent ?? "") const parsedUserAgent = new UAParser(userAgent ?? "")
const os = parsedUserAgent.getOS() const os = parsedUserAgent.getOS()
const isMobileSafari = parsedUserAgent.getBrowser().name !== "Mobile Safari" const isMobileSafari = parsedUserAgent.getBrowser().name === "Mobile Safari"
const isSupported = const isSupported =
os.name !== "iOS" || os.name !== "iOS" ||
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0") versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
return json({ isSupported, isMobileSafari }) return json({
isSupported,
isMobileSafari,
name: os.name,
version: os.version,
})
} }
export default function Index() { export default function Index() {
const { isSupported, isMobileSafari } = useLoaderData<typeof loader>() const { isSupported, isMobileSafari, name, version } =
useLoaderData<typeof loader>()
console.log(name, version)
return ( return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
@ -54,23 +63,22 @@ function LandingMessage({
}) { }) {
const { installed } = useInstallState({ isSupported, isMobileSafari }) const { installed } = useInstallState({ isSupported, isMobileSafari })
const [rendered, setRendered] = useState(false)
const [isInstalled, setIsInstalled] = useState(installed) const [isInstalled, setIsInstalled] = useState(installed)
useEffect(() => { return (
setRendered(true) <ClientOnly fallback={<div>Loading</div>}>
}, []) {() =>
isInstalled ? (
return !rendered ? ( <div>Your Notifications</div>
<div>Loading</div> ) : (
) : isInstalled ? ( <InstallPrompts
<div>Your Notifications</div> isMobileSafari={isMobileSafari}
) : ( isSupported={isSupported}
<InstallPrompts notificationsEnabled={false}
isMobileSafari={isMobileSafari} onInstallComplete={() => setIsInstalled(true)}
isSupported={isSupported} />
notificationsEnabled={false} )
onInstallComplete={() => setIsInstalled(true)} }
/> </ClientOnly>
) )
} }

View File

@ -1,4 +1,4 @@
import { usePush } from "remix-pwa-monorepo/packages/push/client/hook" import { usePush } from "./usePush"
type IOSInstallStep = type IOSInstallStep =
| "loading" | "loading"
@ -14,7 +14,7 @@ export default function useInstallState({
isSupported: boolean isSupported: boolean
isMobileSafari: boolean isMobileSafari: boolean
}) { }) {
const isClient = typeof window !== undefined const isClient = typeof window !== "undefined"
if (!isClient) if (!isClient)
return { return {
@ -34,13 +34,15 @@ export default function useInstallState({
matchMedia("(dislay-mode: standalone)").matches matchMedia("(dislay-mode: standalone)").matches
return { return {
step: !isMobileSafari step: !isSupported
? "open safari" ? "unsupported"
: !isRunningPWA && isMobileSafari : !isMobileSafari
? "install" ? "open safari"
: !notificationsEnabled : !isRunningPWA && isMobileSafari
? "enable notifications" ? "install"
: (null as IOSInstallStep | null), : !notificationsEnabled
? "enable notifications"
: (null as IOSInstallStep | null),
installed: notificationsEnabled, installed: notificationsEnabled,
} }
} }

183
app/usePush.ts Normal file
View File

@ -0,0 +1,183 @@
import { useEffect, useState } from "react"
export type PushObject = {
/**
* Boolean state indicating whether the user is subscribed to push notifications or not.
*/
isSubscribed: boolean
/**
* The push subscription object
*/
pushSubscription: PushSubscription | null
/**
* Request permission for push notifications
* @returns The permission status of the push notifications
*/
requestPermission: () => NotificationPermission
/**
* Utility to subscribe to push notifications
* @param publicKey the public vapid key
* @param callback a callback function to be called when the subscription is successful
* @param errorCallback a callback function to be called if the subscription fails
*/
subscribeToPush: (
publicKey: string,
callback?: (subscription: PushSubscription) => void,
errorCallback?: (error: any) => void
) => void
/**
* Utility to unsubscribe from push notifications
* @param callback a callback function to be called when the unsubscription is successful
* @param errorCallback a callback function to be called if the unsubscription fails
*/
unsubscribeFromPush: (
callback?: () => void,
errorCallback?: (error: any) => void
) => void
/**
* Boolean state indicating whether the user has allowed sending of push notifications or not.
*/
canSendPush: boolean
}
/**
* Push API hook - contains all your necessities for handling push notifications in the client
*/
export const usePush = (): PushObject => {
const [swRegistration, setSWRegistration] =
useState<ServiceWorkerRegistration | null>(null)
const [isSubscribed, setIsSubscribed] = useState<boolean>(false)
const [pushSubscription, setPushSubscription] =
useState<PushSubscription | null>(null)
const [canSendPush, setCanSendPush] = useState<boolean>(false)
const requestPermission = () => {
if (canSendPush) return "granted"
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
setCanSendPush(true)
return permission
} else {
setCanSendPush(false)
return permission
}
})
return "default"
}
const subscribeToPush = (
publicKey: string,
callback?: (subscription: PushSubscription) => void,
errorCallback?: (error: any) => void
) => {
if (swRegistration === null || swRegistration.pushManager === undefined)
return
swRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
})
.then(
(subscription) => {
setIsSubscribed(true)
setPushSubscription(subscription)
callback && callback(subscription)
},
(error) => {
errorCallback && errorCallback(error)
}
)
}
const unsubscribeFromPush = (
callback?: () => void,
errorCallback?: (error: any) => void
) => {
if (swRegistration === null || swRegistration.pushManager === undefined)
return
swRegistration.pushManager
.getSubscription()
.then((subscription) => {
if (subscription) {
subscription.unsubscribe().then(
() => {
setIsSubscribed(false)
setPushSubscription(null)
callback && callback()
},
(error) => {
errorCallback && errorCallback(error)
}
)
}
})
.catch((error) => {
errorCallback && errorCallback(error)
})
}
useEffect(() => {
if (typeof window === "undefined") return
const getRegistration = async () => {
if ("serviceWorker" in navigator) {
try {
const _registration = await navigator.serviceWorker.getRegistration()
setSWRegistration(_registration ?? null)
} catch (err) {
console.error("Error getting service worker registration:", err)
}
} else {
console.warn("Service Workers are not supported in this browser.")
}
}
const handleControllerChange = () => {
getRegistration()
}
if ("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange
)
}
getRegistration()
return () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange
)
}
}
}, [])
useEffect(() => {
if (swRegistration && swRegistration.pushManager !== undefined) {
swRegistration.pushManager.getSubscription().then((subscription) => {
setIsSubscribed(!!subscription)
setPushSubscription(subscription)
})
Notification.permission === "granted"
? setCanSendPush(true)
: setCanSendPush(false)
}
}, [swRegistration])
return {
isSubscribed,
pushSubscription,
requestPermission,
subscribeToPush,
unsubscribeFromPush,
canSendPush,
}
}

View File

@ -1,5 +1,4 @@
import { test, expect, Page } from "@playwright/test" import { test, expect, Page } from "@playwright/test"
import { before } from "node:test"
const { describe, beforeEach, skip, use } = test const { describe, beforeEach, skip, use } = test
@ -21,7 +20,7 @@ describe("when user is on iOS", () => {
describe("and the browser is not safari", () => { describe("and the browser is not safari", () => {
use({ use({
userAgent: userAgent:
"Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en-gb) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3", "Mozilla/5.0 (iPhone; U; CPU iPhone OS 16_4 like Mac OS X; en-gb) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3",
}) })
test("the user is told they need to install through Safari, because reasons, I guess", async ({ test("the user is told they need to install through Safari, because reasons, I guess", async ({
@ -29,9 +28,9 @@ describe("when user is on iOS", () => {
}) => { }) => {
await page.goto("/") await page.goto("/")
const installText = await page.getByText(/Safari/) const safariText = await page.getByText(/Open tackupnow.com in Safari/)
await expect(installText).toBeAttached() await expect(safariText).toBeAttached()
}) })
}) })
@ -58,7 +57,7 @@ describe("when user is on iOS", () => {
await stubNotifications(page, { permission: "default" }) await stubNotifications(page, { permission: "default" })
await page.goto("/") await page.goto("/")
const enableButton = await page.getByText(/Enable notifications/) const enableButton = await page.getByText(/Enable Notifications/)
await expect(enableButton).toBeAttached() await expect(enableButton).toBeAttached()
}) })
@ -80,7 +79,7 @@ describe("when user is on iOS", () => {
await page.goto("/") await page.goto("/")
const requestPromise = page.waitForRequest("/api/subscribe") const requestPromise = page.waitForRequest("/api/subscribe")
await page.getByText(/Enable notifications/).click() await page.getByText(/Enable Notifications/).click()
const request = await requestPromise const request = await requestPromise
await expect(request.postDataJSON()).toEqual({ hi: "subscription" }) await expect(request.postDataJSON()).toEqual({ hi: "subscription" })
@ -92,7 +91,7 @@ describe("when user is on iOS", () => {
await page.goto("/") await page.goto("/")
const requestPromise = page.waitForRequest("/api/subscribe") const requestPromise = page.waitForRequest("/api/subscribe")
await page.getByText(/Enable notifications/).click() await page.getByText(/Enable Notifications/).click()
await requestPromise await requestPromise
const yourNotificationsHeading = const yourNotificationsHeading =
@ -112,7 +111,7 @@ describe("when user is on iOS", () => {
await page.goto("/") await page.goto("/")
await page.evaluate(async () => await navigator.serviceWorker.ready) await page.evaluate(async () => await navigator.serviceWorker.ready)
const notificationText = await page.getByText(/Enable notifications/) const notificationText = await page.getByText(/Enable Notifications/)
await expect(notificationText).not.toBeAttached() await expect(notificationText).not.toBeAttached()
}) })

88
package-lock.json generated
View File

@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"name": "site", "name": "site",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@remix-pwa/sw": "^3.0.9", "@remix-pwa/sw": "^3.0.9",
"@remix-pwa/worker-runtime": "^2.1.4", "@remix-pwa/worker-runtime": "^2.1.4",
@ -17,8 +18,6 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remix-pwa-monorepo": "github:remix-pwa/monorepo#main",
"remix-utils": "^7.6.0",
"semver": "^7.6.3", "semver": "^7.6.3",
"ua-parser-js": "^1.0.39" "ua-parser-js": "^1.0.39"
}, },
@ -14298,91 +14297,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remix-pwa-monorepo": {
"resolved": "git+ssh://git@github.com/remix-pwa/monorepo.git#dda9d68b1c69642679d6ff17658f21fe24c668d6",
"license": "MIT",
"workspaces": [
"packages/cli",
"packages/client",
"packages/dev",
"packages/eslint-config",
"packages/lint-staged-config",
"packages/push",
"packages/sw",
"packages/sync",
"packages/worker-runtime",
"playground"
],
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/remix-utils": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz",
"integrity": "sha512-BPhCUEy+nwrhDDDg2v3+LFSszV6tluMbeSkbffj2o4tqZxt5Kn69Y9sNpGxYLAj8gjqeYDuxjv55of+gYnnykA==",
"dependencies": {
"type-fest": "^4.3.3"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@remix-run/cloudflare": "^2.0.0",
"@remix-run/deno": "^2.0.0",
"@remix-run/node": "^2.0.0",
"@remix-run/react": "^2.0.0",
"@remix-run/router": "^1.7.2",
"crypto-js": "^4.1.1",
"intl-parse-accept-language": "^1.0.0",
"is-ip": "^5.0.1",
"react": "^18.0.0",
"zod": "^3.22.4"
},
"peerDependenciesMeta": {
"@remix-run/cloudflare": {
"optional": true
},
"@remix-run/deno": {
"optional": true
},
"@remix-run/node": {
"optional": true
},
"@remix-run/react": {
"optional": true
},
"@remix-run/router": {
"optional": true
},
"crypto-js": {
"optional": true
},
"intl-parse-accept-language": {
"optional": true
},
"is-ip": {
"optional": true
},
"react": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/remix-utils/node_modules/type-fest": {
"version": "4.26.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz",
"integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View File

@ -10,7 +10,8 @@
"start": "remix-serve ./build/server/index.js", "start": "remix-serve ./build/server/index.js",
"typecheck": "tsc", "typecheck": "tsc",
"watch": "jest --watch --config=jest.config.ts", "watch": "jest --watch --config=jest.config.ts",
"test": "jest --config=jest.config.ts" "test": "jest --config=jest.config.ts",
"postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@remix-pwa/sw": "^3.0.9", "@remix-pwa/sw": "^3.0.9",
@ -24,8 +25,6 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remix-pwa-monorepo": "github:remix-pwa/monorepo#main",
"remix-utils": "^7.6.0",
"semver": "^7.6.3", "semver": "^7.6.3",
"ua-parser-js": "^1.0.39" "ua-parser-js": "^1.0.39"
}, },