Repository navigation
⚡ Rule pages - Do not render a page again when its content did not change #2805
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bradystroud
wants to merge
2
commits into
main
Choose a base branch
from
bs/2801-rule-page-revalidate
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| /** | ||
| * @jest-environment node | ||
| */ | ||
| import { revalidatePath, revalidateTag } from "next/cache"; | ||
| import { POST } from "@/app/api/revalidate/route"; | ||
| import { GLOBAL_SETTINGS_TAG } from "@/lib/services/global"; | ||
|
|
||
| jest.mock("next/cache", () => ({ revalidatePath: jest.fn(), revalidateTag: jest.fn(), unstable_cache: (fn: unknown) => fn })); | ||
| jest.mock("@/tina/__generated__/client", () => ({ __esModule: true, default: { queries: {} } })); | ||
|
|
||
| const SECRET = "test-secret"; | ||
|
|
||
| const send = (body: unknown, secret: string = SECRET) => | ||
| POST( | ||
| new Request("http://localhost/api/revalidate", { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json", "x-revalidate-secret": secret }, | ||
| body: JSON.stringify(body), | ||
| }) | ||
| ); | ||
|
|
||
| const paths = () => (revalidatePath as jest.Mock).mock.calls.map(([path]) => path); | ||
| const tags = () => (revalidateTag as jest.Mock).mock.calls.map(([tag]) => tag); | ||
|
|
||
| describe("POST /api/revalidate", () => { | ||
| const savedSecret = process.env.TINA_WEBHOOK_SECRET; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| process.env.TINA_WEBHOOK_SECRET = SECRET; | ||
| }); | ||
| afterAll(() => { | ||
| process.env.TINA_WEBHOOK_SECRET = savedSecret; | ||
| }); | ||
|
|
||
| it("rejects a request that has the wrong secret", async () => { | ||
| const res = await send({ type: "content.modified", paths: ["global/index.json"] }, "wrong"); | ||
|
|
||
| expect(res.status).toBe(401); | ||
| expect(revalidatePath).not.toHaveBeenCalled(); | ||
| expect(revalidateTag).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("ignores an event type that is not a content change", async () => { | ||
| const res = await send({ type: "content.removed", paths: ["public/uploads/rules/a-rule/rule.mdx"] }); | ||
|
|
||
| expect(await res.json()).toMatchObject({ revalidated: false, ignored: true }); | ||
| expect(revalidatePath).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("revalidates only the page and the card of an edited rule", async () => { | ||
| const res = await send({ type: "content.modified", paths: ["public/uploads/rules/3-steps-to-a-pbi/rule.mdx"] }); | ||
|
|
||
| expect(await res.json()).toEqual({ revalidated: true, routes: ["/3-steps-to-a-pbi", "/3-steps-to-a-pbi/opengraph-image"] }); | ||
| expect(paths()).toEqual(["/3-steps-to-a-pbi", "/3-steps-to-a-pbi/opengraph-image"]); | ||
| expect(revalidateTag).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("also revalidates the rule lists and the rule count for a new rule", async () => { | ||
| await send({ type: "content.added", paths: ["public/uploads/rules/a-new-rule/rule.mdx"] }); | ||
|
|
||
| expect(paths()).toEqual(["/a-new-rule", "/a-new-rule/opengraph-image", "/api/rules"]); | ||
| expect(revalidateTag).toHaveBeenCalledWith("latest-rules", { expire: 0 }); | ||
| expect(revalidateTag).toHaveBeenCalledWith("rule-count", { expire: 0 }); | ||
| }); | ||
|
|
||
| it("revalidates the category page, the home page and the category data for an edited category", async () => { | ||
| await send({ type: "content.modified", paths: ["categories/communication/rules-to-better-email.mdx"] }); | ||
|
|
||
| expect(paths()).toEqual(["/opengraph-image", "/", "/rules-to-better-email", "/rules-to-better-email/opengraph-image"]); | ||
| expect(tags()).toEqual(["category-rule-data"]); | ||
| }); | ||
|
|
||
| it("does not revalidate a category page for a top category index file", async () => { | ||
| await send({ type: "content.modified", paths: ["categories/communication/index.mdx"] }); | ||
|
|
||
| expect(paths()).toEqual(["/opengraph-image", "/"]); | ||
| }); | ||
|
|
||
| // The root layout reads the global settings, so no path identifies the pages. The tag is on each page. | ||
| it("marks all pages stale for a change of the global settings", async () => { | ||
| const res = await send({ type: "content.modified", paths: ["global/index.json"] }); | ||
|
|
||
| expect(await res.json()).toEqual({ revalidated: true, routes: [] }); | ||
| expect(revalidateTag).toHaveBeenCalledTimes(1); | ||
| expect(revalidateTag).toHaveBeenCalledWith(GLOBAL_SETTINGS_TAG, "max"); | ||
| expect(revalidatePath).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("does not use the global settings tag for a rule or a category change", async () => { | ||
| await send({ type: "content.added", paths: ["public/uploads/rules/a-new-rule/rule.mdx", "categories/communication/rules-to-better-email.mdx"] }); | ||
|
|
||
| expect(tags()).not.toContain(GLOBAL_SETTINGS_TAG); | ||
| }); | ||
|
|
||
| it("revalidates nothing for a file that no page reads through this route", async () => { | ||
| const res = await send({ type: "content.modified", paths: ["README.md"] }); | ||
|
|
||
| expect(await res.json()).toEqual({ revalidated: true, routes: [] }); | ||
| expect(revalidatePath).not.toHaveBeenCalled(); | ||
| expect(revalidateTag).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| /** | ||
| * @jest-environment node | ||
| */ | ||
| import { unstable_cache } from "next/cache"; | ||
| import { getDiscussionNumberForGuid } from "@/lib/services/github/giscusDiscussions.service"; | ||
|
|
||
| jest.mock("next/cache", () => ({ unstable_cache: jest.fn((fn: unknown) => fn) })); | ||
| jest.mock("@/lib/services/github/github.utils", () => ({ getGitHubAppToken: jest.fn(async () => "token") })); | ||
|
|
||
| // The module calls unstable_cache one time, when it loads | ||
| const [, , cacheOptions] = (unstable_cache as jest.Mock).mock.calls[0]; | ||
|
|
||
| const discussionsPage = (nodes: { number: number; title: string; comments: { totalCount: number } }[]) => | ||
| new Response(JSON.stringify({ data: { repository: { discussions: { pageInfo: { hasNextPage: false, endCursor: null }, nodes } } } }), { status: 200 }); | ||
|
|
||
| describe("giscus discussion numbers", () => { | ||
| const savedEnv = { ...process.env }; | ||
| const savedFetch = global.fetch; | ||
|
|
||
| beforeEach(() => { | ||
| process.env.NEXT_PUBLIC_GITHUB_ORG = "SSWConsulting"; | ||
| process.env.NEXT_PUBLIC_GISCUS_REPO_NAME = "SSW.Rules.Content"; | ||
| }); | ||
| afterEach(() => { | ||
| process.env = { ...savedEnv }; | ||
| global.fetch = savedFetch; | ||
| }); | ||
|
|
||
| // Each rule page calls the cached function, so a number here makes Next.js render each rule page again after that time | ||
| it("gives the rule pages no revalidation period", () => { | ||
| expect(cacheOptions.revalidate).toBeUndefined(); | ||
| expect(cacheOptions.tags).toEqual(["giscus-discussion-numbers"]); | ||
| }); | ||
|
|
||
| it("resolves a rule GUID to the discussion that has the most comments", async () => { | ||
| global.fetch = jest.fn(async () => | ||
| discussionsPage([ | ||
| { number: 12, title: "ABC-123", comments: { totalCount: 0 } }, | ||
| { number: 7, title: "abc-123 ", comments: { totalCount: 4 } }, | ||
| { number: 9, title: "other-guid", comments: { totalCount: 1 } }, | ||
| ]) | ||
| ) as unknown as typeof fetch; | ||
|
|
||
| await expect(getDiscussionNumberForGuid(" ABC-123 ")).resolves.toBe(7); | ||
| await expect(getDiscussionNumberForGuid("no-discussion-yet")).resolves.toBeNull(); | ||
| }); | ||
|
|
||
| // Keep this test last: after a failure, the service does not call GitHub for some minutes | ||
| it("returns null when GitHub fails, so the page falls back to the search mapping", async () => { | ||
| const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); | ||
| global.fetch = jest.fn(async () => new Response("", { status: 502, statusText: "Bad Gateway" })) as unknown as typeof fetch; | ||
|
|
||
| await expect(getDiscussionNumberForGuid("abc-123")).resolves.toBeNull(); | ||
| expect(warn).toHaveBeenCalled(); | ||
| warn.mockRestore(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| /** | ||
| * @jest-environment node | ||
| */ | ||
| import { unstable_cache } from "next/cache"; | ||
| import { GLOBAL_SETTINGS_TAG, getGlobalSettings } from "@/lib/services/global"; | ||
| import client from "@/tina/__generated__/client"; | ||
|
|
||
| jest.mock("next/cache", () => ({ unstable_cache: jest.fn((fn: unknown) => fn) })); | ||
| jest.mock("@/tina/__generated__/client", () => ({ __esModule: true, default: { queries: { global: jest.fn() } } })); | ||
|
|
||
| const globalQuery = (client as any).queries.global as jest.Mock; | ||
| // The module calls unstable_cache one time, when it loads | ||
| const [, , cacheOptions] = (unstable_cache as jest.Mock).mock.calls[0]; | ||
|
|
||
| beforeEach(() => globalQuery.mockReset()); | ||
|
|
||
| describe("getGlobalSettings", () => { | ||
| // The root layout calls it, so a number here makes Next.js render each page of the site again after that time | ||
| it("gives the pages no revalidation period", () => { | ||
| expect(cacheOptions.revalidate).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("puts the global settings tag on the pages", () => { | ||
| expect(cacheOptions.tags).toEqual([GLOBAL_SETTINGS_TAG]); | ||
| }); | ||
|
|
||
| it("returns the global settings document", async () => { | ||
| const settings = { theme: { color: "blue" } }; | ||
| globalQuery.mockResolvedValue({ data: { global: settings } }); | ||
|
|
||
| await expect(getGlobalSettings()).resolves.toEqual(settings); | ||
| expect(globalQuery).toHaveBeenCalledWith({ relativePath: "index.json" }, {}); | ||
| }); | ||
|
|
||
| // unstable_cache stores only a result. An error must stay an error, or the cache keeps a page without settings. | ||
| it("tries a throttled request again, then rejects", async () => { | ||
| jest.useFakeTimers(); | ||
| const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); | ||
| globalQuery.mockRejectedValue(new Error("Throughput exceeds the current capacity of your table or index")); | ||
|
|
||
| try { | ||
| const result = expect(getGlobalSettings()).rejects.toThrow("Throughput exceeds"); | ||
| await jest.runAllTimersAsync(); | ||
| await result; | ||
| expect(globalQuery).toHaveBeenCalledTimes(5); | ||
| } finally { | ||
| warn.mockRestore(); | ||
| jest.useRealTimers(); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { unstable_cache } from "next/cache"; | ||
| import { tinaRetryOptions, withTinaRetry } from "@/lib/tina/tinaRequest"; | ||
| import client from "@/tina/__generated__/client"; | ||
|
|
||
| // The root layout reads the global settings, so this tag is on each page of the site. | ||
| // A revalidation of the tag renders all pages again. | ||
| export const GLOBAL_SETTINGS_TAG = "global-settings"; | ||
|
|
||
| async function fetchGlobalSettings() { | ||
| const { data } = await withTinaRetry("global", (attempt) => client.queries.global({ relativePath: "index.json" }, tinaRetryOptions(attempt))); | ||
| return data.global; | ||
| } | ||
|
|
||
| // - No `revalidate`: Next.js copies a number to each page that calls this function and renders the page again | ||
| // after that time. The root layout calls it, so a number here becomes the revalidation period of the whole | ||
| // site. A change gets to the pages through GLOBAL_SETTINGS_TAG. | ||
| // - `unstable_cache`, not `fetchOptions` on the Tina request: in a build, the Tina client answers a repeated | ||
| // query from its own disk cache and does not call `fetch`. Tags and periods in `fetchOptions` then apply only | ||
| // to the few pages that send the request. | ||
| export const getGlobalSettings = unstable_cache(fetchGlobalSettings, ["global-settings"], { tags: [GLOBAL_SETTINGS_TAG] }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./globalSettings.service"; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.