From 8abd7d7acc4d010aca94fb1797dd7df232403622 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sun, 14 Dec 2025 13:56:52 +0800 Subject: [PATCH 1/2] fix: apply macros before merging hooks in group method The group method was merging hooks before expanding macro options, causing macro-defined beforeHandle hooks to run after nested plugin resolve hooks. This fix calls applyMacro() on the hook object before mergeHook() to ensure correct lifecycle ordering. Closes #1586 --- src/index.ts | 4 + test/core/macro-lifecycle.test.ts | 142 ++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 test/core/macro-lifecycle.test.ts diff --git a/src/index.ts b/src/index.ts index 2acceb20..d2e873ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4042,6 +4042,10 @@ export default class Elysia< } = schemaOrRun const localHook = hooks as AnyLocalHook + // Apply macros to expand group options before merging + // This ensures macro-defined hooks run before nested plugin hooks + this.applyMacro(hook) + const hasStandaloneSchema = body || headers || query || params || cookie || response diff --git a/test/core/macro-lifecycle.test.ts b/test/core/macro-lifecycle.test.ts new file mode 100644 index 00000000..cbf625f0 --- /dev/null +++ b/test/core/macro-lifecycle.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'bun:test' +import { Elysia } from '../../src' + +describe('macro beforeHandle lifecycle order', () => { + it('should run macro beforeHandle before nested plugin resolve', async () => { + const executionOrder: string[] = [] + + // Auth service with resolve + macro + const authService = new Elysia({ name: 'auth-service' }) + .resolve(() => { + executionOrder.push('authService.resolve') + return { userId: undefined } // Simulating no auth + }) + .macro({ + isSignedIn: { + beforeHandle({ userId }) { + executionOrder.push('isSignedIn.beforeHandle') + if (!userId) throw new Error('Unauthorized') + } + } + }) + .as('scoped') + + // DB client that requires userId + const dbClient = new Elysia({ name: 'db-client' }) + .resolve((ctx) => { + executionOrder.push('dbClient.resolve') + const userId = (ctx as { userId?: string }).userId + if (!userId) throw new Error('User ID is required') + return { db: { userId } } + }) + .as('scoped') + + // Feature module using dbClient + const feature = new Elysia({ name: 'feature' }) + .use(dbClient) + .get('/', ({ db }) => `Hello ${db.userId}`) + + // Main app + const app = new Elysia() + .use(authService) + .group('/v1', { isSignedIn: true }, (app) => app.use(feature)) + + const response = await app.handle(new Request('http://localhost/v1/')) + + // The macro's beforeHandle should run BEFORE dbClient's resolve + // So we should get "Unauthorized" error, not "User ID is required" + const body = await response.text() + + console.log('Execution order:', executionOrder) + console.log('Response:', body) + + // Expected order: authService.resolve -> isSignedIn.beforeHandle (throws) + // dbClient.resolve should NOT run because beforeHandle throws first + expect(executionOrder).toContain('authService.resolve') + expect(executionOrder).toContain('isSignedIn.beforeHandle') + expect(executionOrder).not.toContain('dbClient.resolve') + expect(body).toContain('Unauthorized') + }) + + it('should run hooks in registration order within same queue', async () => { + const executionOrder: string[] = [] + + const app = new Elysia() + .resolve(() => { + executionOrder.push('resolve1') + return { val1: 1 } + }) + .onBeforeHandle(() => { + executionOrder.push('beforeHandle1') + }) + .resolve(() => { + executionOrder.push('resolve2') + return { val2: 2 } + }) + .onBeforeHandle(() => { + executionOrder.push('beforeHandle2') + }) + .get('/', () => 'ok') + + await app.handle(new Request('http://localhost/')) + + console.log('Execution order:', executionOrder) + + // According to docs, resolve and beforeHandle share the same queue + // Order should be: resolve1 -> beforeHandle1 -> resolve2 -> beforeHandle2 + expect(executionOrder).toEqual([ + 'resolve1', + 'beforeHandle1', + 'resolve2', + 'beforeHandle2' + ]) + }) + + it('should demonstrate the issue with macro in group', async () => { + const executionOrder: string[] = [] + let errorMessage = '' + + const authPlugin = new Elysia({ name: 'auth' }) + .resolve(() => { + executionOrder.push('auth.resolve') + return { userId: 'user123' } // Auth succeeds + }) + .macro({ + requireAuth: { + beforeHandle({ userId }) { + executionOrder.push('requireAuth.beforeHandle') + if (!userId) throw new Error('Unauthorized') + } + } + }) + .as('scoped') + + const dataPlugin = new Elysia({ name: 'data' }) + .resolve((ctx) => { + executionOrder.push('data.resolve') + return { data: 'some data' } + }) + .as('scoped') + + const app = new Elysia() + .use(authPlugin) + .onError(({ error }) => { + errorMessage = error.message + return error.message + }) + .group('/api', { requireAuth: true }, (app) => app.use(dataPlugin).get('/', ({ data }) => data)) + + const response = await app.handle(new Request('http://localhost/api/')) + + console.log('Execution order:', executionOrder) + console.log('Error:', errorMessage) + + // With auth succeeding (userId = 'user123'), all should run + // Expected: auth.resolve -> requireAuth.beforeHandle -> data.resolve + expect(executionOrder).toEqual([ + 'auth.resolve', + 'requireAuth.beforeHandle', + 'data.resolve' + ]) + }) +}) From fc7033301ab84f2ed9a1a3834f29ad113691bc24 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sun, 14 Dec 2025 14:12:15 +0800 Subject: [PATCH 2/2] fix: preserve macro standaloneValidator when merging hooks in group When a macro defines schema validation (body, headers, etc.), the standaloneValidator from applyMacro was being overwritten by the localHook's standaloneValidator during mergeHook. This fix properly merges all three sources: macro's standaloneValidator, localHook's standaloneValidator, and group's explicit schema. Added test case to verify macro schema is preserved when used with nested plugins. --- src/index.ts | 37 ++++++++++++++--------- test/core/macro-lifecycle.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index d2e873ef..f60bcaaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4066,20 +4066,29 @@ export default class Elysia< localHook.error, ...(sandbox.event.error ?? []) ], - standaloneValidator: !hasStandaloneSchema - ? localHook.standaloneValidator - : [ - ...(localHook.standaloneValidator ?? - []), - { - body, - headers, - query, - params, - cookie, - response - } - ] + // Merge macro's standaloneValidator with local and group schema + standaloneValidator: + hook.standaloneValidator || + localHook.standaloneValidator || + hasStandaloneSchema + ? [ + ...(hook.standaloneValidator ?? []), + ...(localHook.standaloneValidator ?? + []), + ...(hasStandaloneSchema + ? [ + { + body, + headers, + query, + params, + cookie, + response + } + ] + : []) + ] + : undefined }), undefined ) diff --git a/test/core/macro-lifecycle.test.ts b/test/core/macro-lifecycle.test.ts index cbf625f0..e3548f37 100644 --- a/test/core/macro-lifecycle.test.ts +++ b/test/core/macro-lifecycle.test.ts @@ -139,4 +139,54 @@ describe('macro beforeHandle lifecycle order', () => { 'data.resolve' ]) }) + + it('should preserve macro schema when merging with nested plugin hooks', async () => { + const { t } = await import('../../src') + + // Macro that adds both beforeHandle and body schema + const validatedMacro = new Elysia({ name: 'validated-macro' }) + .macro({ + validatePayload: { + body: t.Object({ + name: t.String() + }), + beforeHandle() { + // Macro's beforeHandle + } + } + }) + .as('scoped') + + const nestedPlugin = new Elysia({ name: 'nested' }) + .resolve(() => ({ nested: true })) + .as('scoped') + + const app = new Elysia() + .use(validatedMacro) + .group('/api', { validatePayload: true }, (app) => + app.use(nestedPlugin).post('/', ({ body }) => body.name) + ) + + // Valid request - should pass validation + const validResponse = await app.handle( + new Request('http://localhost/api/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'test' }) + }) + ) + expect(validResponse.status).toBe(200) + expect(await validResponse.text()).toBe('test') + + // Invalid request - should fail validation (macro schema should be preserved) + const invalidResponse = await app.handle( + new Request('http://localhost/api/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invalid: 'data' }) + }) + ) + // If macro schema is lost, this would be 200 instead of 422 + expect(invalidResponse.status).toBe(422) + }) })