diff --git a/.changeset/early-results-sip.md b/.changeset/early-results-sip.md new file mode 100644 index 00000000..8499f99a --- /dev/null +++ b/.changeset/early-results-sip.md @@ -0,0 +1,6 @@ +--- +"@knocklabs/client": patch +"@knocklabs/react": patch +--- + +[guides] add dedicated nextjs helper components for detecting location changes diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index ec521ad1..e661d721 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -341,7 +341,7 @@ export class KnockGuideClient { cleanup() { this.unsubscribe(); - this.removeEventListeners(); + this.removeLocationChangeEventListeners(); this.clearGroupStage(); this.clearCounterInterval(); } @@ -1144,6 +1144,7 @@ export class KnockGuideClient { // Define as an arrow func property to always bind this to the class instance. private handleLocationChange = () => { + this.knock.log(`[Guide] .handleLocationChange`); const win = checkForWindow(); if (!win?.location) return; @@ -1223,7 +1224,7 @@ export class KnockGuideClient { } } - private removeEventListeners() { + removeLocationChangeEventListeners() { const win = checkForWindow(); if (!win?.history) return; diff --git a/packages/react/package.json b/packages/react/package.json index 2f5940c8..ab33419b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -46,9 +46,15 @@ "url": "https://github.com/knocklabs/javascript/issues" }, "peerDependencies": { + "next": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, + "peerDependenciesMeta": { + "next": { + "optional": true + } + }, "dependencies": { "@knocklabs/client": "workspace:^", "@knocklabs/react-core": "workspace:^", @@ -79,6 +85,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.14", "jsdom": "^27.0.0", + "next": "15.3.3", "react": "^19.0.0", "react-dom": "^19.0.0", "rimraf": "^6.0.1", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 80214ade..18932e17 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -44,6 +44,8 @@ export { CardView, KnockGuideProvider, GuideToolbar as KnockGuideToolbar, + LocationSensorNextPagesRouter as KnockGuideLocationSensorNextPagesRouter, + LocationSensorNextAppRouter as KnockGuideLocationSensorNextAppRouter, Modal, ModalView, } from "./modules/guide"; diff --git a/packages/react/src/modules/guide/components/LocationSensor/NextAppRouter.tsx b/packages/react/src/modules/guide/components/LocationSensor/NextAppRouter.tsx new file mode 100644 index 00000000..9a20a88c --- /dev/null +++ b/packages/react/src/modules/guide/components/LocationSensor/NextAppRouter.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useGuideContext } from "@knocklabs/react-core"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +import { checkForWindow } from "../../../core/utils"; + +export const LocationSensorNextAppRouter = () => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const queryStr = searchParams.toString(); + + const { client } = useGuideContext(); + + useEffect(() => { + client.removeLocationChangeEventListeners(); + }, [client]); + + useEffect(() => { + const win = checkForWindow(); + if (!win) return; + + client.setLocation(win.location.href); + }, [client, pathname, queryStr]); + + return null; +}; diff --git a/packages/react/src/modules/guide/components/LocationSensor/NextPagesRouter.tsx b/packages/react/src/modules/guide/components/LocationSensor/NextPagesRouter.tsx new file mode 100644 index 00000000..986a67c3 --- /dev/null +++ b/packages/react/src/modules/guide/components/LocationSensor/NextPagesRouter.tsx @@ -0,0 +1,41 @@ +import { useGuideContext } from "@knocklabs/react-core"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +import { checkForWindow } from "../../../core/utils"; + +export const LocationSensorNextPagesRouter = () => { + const router = useRouter(); + const { client } = useGuideContext(); + + useEffect(() => { + const win = checkForWindow(); + if (!win) return; + + // Set the initial location if not yet set. + if (!client.store.state.location) { + client.setLocation(win.location.href); + } + + // Remove any location chagne event listeners on the window object in case + // they are attached. + client.removeLocationChangeEventListeners(); + + // Attach a route change event listener to the nextjs router. Note, here url + // is the pathname and any query parameters of the new route but does not + // include the domain or origin. + const handleRouteChangeComplete = (url: string) => { + client.setLocation(win.location.origin + url); + }; + router.events.on("routeChangeComplete", handleRouteChangeComplete); + + return () => { + router.events.off("routeChangeComplete", handleRouteChangeComplete); + }; + // We want to run this effect once per client instance and `router` is not + // guaranteed to be referentially stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client]); + + return null; +}; diff --git a/packages/react/src/modules/guide/components/LocationSensor/index.ts b/packages/react/src/modules/guide/components/LocationSensor/index.ts new file mode 100644 index 00000000..2cfa1c53 --- /dev/null +++ b/packages/react/src/modules/guide/components/LocationSensor/index.ts @@ -0,0 +1,2 @@ +export { LocationSensorNextAppRouter } from "./NextAppRouter"; +export { LocationSensorNextPagesRouter } from "./NextPagesRouter"; diff --git a/packages/react/src/modules/guide/components/index.ts b/packages/react/src/modules/guide/components/index.ts index 8ebbde6b..dc019553 100644 --- a/packages/react/src/modules/guide/components/index.ts +++ b/packages/react/src/modules/guide/components/index.ts @@ -1,4 +1,8 @@ export { Banner, BannerView } from "./Banner"; export { Card, CardView } from "./Card"; export { GuideToolbar } from "./GuideToolbar"; +export { + LocationSensorNextAppRouter, + LocationSensorNextPagesRouter, +} from "./LocationSensor"; export { Modal, ModalView } from "./Modal"; diff --git a/packages/react/src/modules/guide/index.ts b/packages/react/src/modules/guide/index.ts index ab8455a6..a9517776 100644 --- a/packages/react/src/modules/guide/index.ts +++ b/packages/react/src/modules/guide/index.ts @@ -6,5 +6,7 @@ export { Modal, ModalView, GuideToolbar, + LocationSensorNextAppRouter, + LocationSensorNextPagesRouter, } from "./components"; export { KnockGuideProvider } from "./providers"; diff --git a/yarn.lock b/yarn.lock index 8d9c32e5..4cca5f5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4228,6 +4228,7 @@ __metadata: eslint-plugin-react-refresh: "npm:^0.4.14" jsdom: "npm:^27.0.0" lodash.debounce: "npm:^4.0.8" + next: "npm:15.3.3" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" rimraf: "npm:^6.0.1" @@ -4240,8 +4241,12 @@ __metadata: vitest: "npm:^3.1.1" vitest-axe: "npm:^0.1.0" peerDependencies: + next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + next: + optional: true languageName: unknown linkType: soft