Files
Niklas MischkulnigandGitHub 84457f4e6c Turbopack: compute code hash per use cache function (#94234)
This computes the implementation hash of a given `use cache`:

- it includes the module containing the `use cache` function, plus all transitive imports of that module (including externals via NFT)
- what does it hash
    - for bundled code, it hashes the generated code (so including any AST transforms, inlined env vars, etc)
    - for NFTd files, it hashes the file content on disk
- So this is basically equivalent to hashing the generated JS chunks, except that it's scoped to only what the given actions module uses
- (so notably, if you have multiple use cache functions in a given file, then they will all have the same hash)

<br/>

- [x] Gate behind experimental flag
- [x] Only compute hash for `use cache` functions, not all server actions
- [ ] `final_read_hint` is a problem. Some chunk items are now codgened twice, so it recomputes the AST
- [x] disable for dev for now
- [ ] Measure perf impact


```jsonc
// .next/server/server-reference-manifest.json
{
  "node": {
    "806f4954cfbb75404a19d6d405065ed9059cc0cab2": {
      "workers": {
        "app/rsc/page": {
          "moduleId": "[project]/bench/app-router-server/.next-internal/server/app/rsc/page/actions.js { ACTIONS_MODULE0 => \"[project]/bench/app-router-server/app/rsc/logic.js [app-rsc] (ecmascript)\" } [app-rsc] (server actions loader, ecmascript)",
          "async": false,
          "exportedName": "$$RSC_SERVER_CACHE_0",
          "filename": "bench/app-router-server/app/rsc/logic.js",
          "codeHash": "413e394d9597b35df12828a6a7fd6363" // <------------
        }
      },
      "filename": "bench/app-router-server/app/rsc/logic.js",
      "exportedName": "$$RSC_SERVER_CACHE_0"
    }
  },
  "edge": {},
  "encryptionKey": "jOF2TbpNLjp2oOhl3VPBwGE7luodnei9clJPq/gaYQo="
}
```
2026-06-30 14:50:54 +02:00

166 lines
4.5 KiB
TypeScript

import { nextTestSetup, type NextInstance } from 'e2e-utils'
async function getCodeHashes(
next: NextInstance,
pages?: string[]
): Promise<{ id: string; page: string; codeHash?: string }[]> {
const manifest = await next.readJSON(
'.next/server/server-reference-manifest.json'
)
const hashes: {
id: string
page: string
codeHash?: string
}[] = []
for (const [actionId, entry] of Object.entries<any>(manifest.node)) {
for (const [workerKey, worker] of Object.entries<any>(entry.workers)) {
if (!pages || pages.includes(workerKey)) {
hashes.push({
id: actionId,
page: workerKey,
codeHash: worker?.codeHash,
})
}
}
}
return hashes
}
// `durableUseCacheEntries` is only supported by by Turbopack.
;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)(
'app-dir - use-cache-code-hash',
() => {
const { next } = nextTestSetup({
files: __dirname,
skipStart: true,
})
it('emits codeHash only for use-cache functions', async () => {
await next.build()
const values = Object.values(await getCodeHashes(next))
expect(values.length).toBe(3)
const valuesWithoutCodeHash = values.filter(
(e) => typeof e.codeHash !== 'string'
)
expect(valuesWithoutCodeHash.length).toBe(1)
expect(valuesWithoutCodeHash[0].page).toBe('app/use-server/page')
})
it('codeHash stays stable across identical rebuilds', async () => {
await next.build()
const first = await getCodeHashes(next)
await next.build()
const second = await getCodeHashes(next)
expect(second).toEqual(first)
})
it("changes when the action's own code changes", async () => {
await next.build()
const before = await getCodeHashes(next, ['app/use-cache/page'])
await next.patchFile(
'app/use-cache/logic.tsx',
`import { foo } from './foo'
import { external } from 'external-dep'
export async function logic() {
'use cache'
return \`\${foo()}:\${external()}\` + ":other"
}
`,
async () => {
await next.build()
const after = await getCodeHashes(next, ['app/use-cache/page'])
// Same set of actions, but the hash for the changed action differs.
expect(Object.keys(after)).toEqual(Object.keys(before))
expect(after).not.toEqual(before)
}
)
})
it('codeHash changes when an imported dependency changes', async () => {
await next.build()
const before = await getCodeHashes(next, ['app/use-cache/page'])
await next.patchFile(
'app/use-cache/foo.tsx',
`export function foo() {
return "foo-v2"
}
`,
async () => {
await next.build()
const after = await getCodeHashes(next, ['app/use-cache/page'])
expect(Object.keys(after)).toEqual(Object.keys(before))
expect(after).not.toEqual(before)
}
)
})
it('codeHash changes when an external (node_modules) dependency changes', async () => {
await next.build()
const before = await getCodeHashes(next, ['app/use-cache/page'])
await next.patchFile(
'node_modules/external-dep/index.js',
`export function external() {
return 'external-v2'
}
`,
async () => {
await next.build()
const after = await getCodeHashes(next, ['app/use-cache/page'])
expect(Object.keys(after)).toEqual(Object.keys(before))
expect(after).not.toEqual(before)
}
)
})
it('codeHash does not change when an unrelated file changes', async () => {
await next.build()
const before = await getCodeHashes(next, ['app/use-cache/page'])
await next.patchFile(
'app/use-cache/unrelated.ts',
`export function unrelated() {
return 'unrelated-v2'
}
`,
async () => {
await next.build()
const after = await getCodeHashes(next, ['app/use-cache/page'])
expect(after).toEqual(before)
}
)
})
it('codeHash does not change when a client file changes', async () => {
await next.build()
const before = await getCodeHashes(next, ['app/use-cache-client/page'])
await next.patchFile(
'app/use-cache-client/data.ts',
`export function data() {
return 'data-v2'
}
`,
async () => {
await next.build()
const after = await getCodeHashes(next, ['app/use-cache-client/page'])
expect(after).toEqual(before)
}
)
})
}
)