From c84e6dcbbd8d008690487ddb829dbe8376000a6d Mon Sep 17 00:00:00 2001 From: Mingqing Ye Date: Sun, 31 May 2026 21:33:17 +0800 Subject: [PATCH 01/11] test(core): cover custom CSS injection in experience SSR --- .../src/middleware/koa-experience-ssr.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/core/src/middleware/koa-experience-ssr.test.ts b/packages/core/src/middleware/koa-experience-ssr.test.ts index 253a44e1cc01..ad323f09e1f3 100644 --- a/packages/core/src/middleware/koa-experience-ssr.test.ts +++ b/packages/core/src/middleware/koa-experience-ssr.test.ts @@ -77,4 +77,73 @@ describe('koaExperienceSsr()', () => { })});` ); }); + + it('should inline custom CSS into the when present', async () => { + (tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce( + { ...mockSignInExperience, customCss: '.foo { color: red; }' } + ); + + const ctx = { + ...baseCtx, + path: '/', + body: ``, + }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + + expect(ctx.body).toContain(''); + }); + + it('should escape the `` sequence in custom CSS to avoid breaking out of the tag', async () => { + (tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce( + { + ...mockSignInExperience, + customCss: 'body::before { content: ""; }', + } + ); + + const ctx = { + ...baseCtx, + path: '/', + body: ``, + }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + + // The dangerous `"'); + }); + + it('should escape characters in the SSR JSON so embedded data cannot break out of the ' } } + ); + + const ctx = { + ...baseCtx, + path: '/', + body: ``, + }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + + // Custom content is NOT inlined into a ` + ) + : ctx.body; + + ctx.body = htmlWithCss.replace( ssrPlaceholder, - `Object.freeze(${JSON.stringify({ + `Object.freeze(${serializeSsrData({ signInExperience: { ...pick(logtoUiCookie, 'appId', 'organizationId'), data: signInExperience, }, phrases: { lng: language, data: phrases }, - } satisfies SsrData)})` + })})` ); }; } From 197107238170dafc9be44346bca1d15b2088d4fb Mon Sep 17 00:00:00 2001 From: Mingqing Ye Date: Sun, 31 May 2026 21:39:56 +0800 Subject: [PATCH 03/11] chore: add changeset for experience custom CSS flash fix --- .changeset/experience-custom-css-no-flash.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/experience-custom-css-no-flash.md diff --git a/.changeset/experience-custom-css-no-flash.md b/.changeset/experience-custom-css-no-flash.md new file mode 100644 index 000000000000..0aa7d1e5e963 --- /dev/null +++ b/.changeset/experience-custom-css-no-flash.md @@ -0,0 +1,7 @@ +--- +"@logto/core": patch +--- + +fix a flash of built-in styles on the hosted sign-in experience when custom CSS is configured + +Custom CSS was injected on the client via react-helmet, which mutates `` asynchronously after the page had already painted with the built-in styles. The server-rendered experience HTML now inlines the configured custom CSS into ``, so it is part of the cascade on the first paint. The `` sequence in custom CSS is escaped so it cannot terminate the style element early, and the SSR data embedded in the inline `` that - // should remain is the genuine close of the `window.logtoSsr` script. If the SSR data were not - // escaped, its `` would appear too and break out of the inline script. - expect(ctx.body.match(/<\/script>/g)?.length).toBe(1); + // The `` carried in the SSR data must be emitted as its escaped form (``), + // never as a literal tag that would close the inline `window.logtoSsr` ` occurrences) is robust to the served template adding + // its own `, + }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + + // The `\uXXXX` escapes must decode back to the original characters when parsed, so the escaping is + // safe (no data corruption) while still preventing tag breakout. + const serialized = /Object\.freeze\((?.+)\)/.exec(ctx.body)?.groups?.json; + expect(serialized).toBeTruthy(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test parses known JSON + const parsed = JSON.parse(serialized!); + expect(parsed.signInExperience.data.customContent['/sign-in']).toBe('&'); }); it('should not inline custom CSS in preview mode', async () => { diff --git a/packages/core/src/middleware/koa-experience-ssr.ts b/packages/core/src/middleware/koa-experience-ssr.ts index c8dd3a1c209d..260938646da5 100644 --- a/packages/core/src/middleware/koa-experience-ssr.ts +++ b/packages/core/src/middleware/koa-experience-ssr.ts @@ -74,13 +74,12 @@ export default function koaExperienceSsr a frame later. react-helmet still re-asserts the same CSS client-side (harmless) // and keeps live preview working. const { customCss } = signInExperience; - // Skip the inline in preview mode: the console preview iframe drives styling live via postMessage - // + react-helmet, so inlining the *saved* CSS here could briefly show it and even leak rules the - // user is editing out. Live preview has no FOUC to fix. - // Defuse `` is absent (graceful fallback to client-side helmet injection). + // Inline the custom CSS only outside preview mode. In preview the console iframe drives styling live + // via postMessage + react-helmet, so inlining the *saved* CSS here could briefly show it and even + // leak rules the user is editing out — live preview has no FOUC to fix. `` replace is a no-op if absent, falling back to client-side helmet injection. const htmlWithCss = customCss && ctx.query.preview !== 'true' ? ctx.body.replace( From d275df27eef33de65528f6c85a605ed330447516 Mon Sep 17 00:00:00 2001 From: Mingqing Ye Date: Sun, 31 May 2026 22:55:41 +0800 Subject: [PATCH 05/11] chore: rename changeset to conventional three-word name --- .../{experience-custom-css-no-flash.md => gentle-otters-cheer.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changeset/{experience-custom-css-no-flash.md => gentle-otters-cheer.md} (100%) diff --git a/.changeset/experience-custom-css-no-flash.md b/.changeset/gentle-otters-cheer.md similarity index 100% rename from .changeset/experience-custom-css-no-flash.md rename to .changeset/gentle-otters-cheer.md From 0d8e71154f6bd0f3d5f621b4c26c77931746fe98 Mon Sep 17 00:00:00 2001 From: Mingqing Ye Date: Thu, 4 Jun 2026 10:29:06 +0800 Subject: [PATCH 06/11] refactor(core): condense inline custom CSS comments --- .../core/src/middleware/koa-experience-ssr.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/core/src/middleware/koa-experience-ssr.ts b/packages/core/src/middleware/koa-experience-ssr.ts index 260938646da5..5663203cec5f 100644 --- a/packages/core/src/middleware/koa-experience-ssr.ts +++ b/packages/core/src/middleware/koa-experience-ssr.ts @@ -67,19 +67,14 @@ export default function koaExperienceSsr BEFORE substituting the SSR placeholder, so the - // `` match can only hit the genuine document head — never a `` that might appear in - // the injected SSR JSON. Having the CSS in on the first byte puts it in the cascade for the - // first paint, removing the flash where built-in styles render before react-helmet injects the - // custom end-tag variants --- .../src/middleware/koa-experience-ssr.test.ts | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/core/src/middleware/koa-experience-ssr.test.ts b/packages/core/src/middleware/koa-experience-ssr.test.ts index d5fdb017c1bf..b6417894d750 100644 --- a/packages/core/src/middleware/koa-experience-ssr.test.ts +++ b/packages/core/src/middleware/koa-experience-ssr.test.ts @@ -70,12 +70,15 @@ describe('koaExperienceSsr()', () => { await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); expect(next).toHaveBeenCalledTimes(1); expect(ctx.body).not.toContain(ssrPlaceholder); - expect(ctx.body).toContain( - `const logtoSsr=Object.freeze(${JSON.stringify({ - signInExperience: { data: mockSignInExperience }, - phrases: { lng: 'en', data: phrases }, - })});` - ); + expect(ctx.body).toContain('const logtoSsr=Object.freeze('); + + // Extract and parse the injected JSON rather than comparing against a bare `JSON.stringify`, which + // would diverge from `serializeSsrData`'s `<`/`>`/`&` escaping the moment the mock gains such a char. + const serialized = /Object\.freeze\((?.+)\)/.exec(ctx.body)?.groups?.json; + expect(JSON.parse(serialized!)).toEqual({ + signInExperience: { data: mockSignInExperience }, + phrases: { lng: 'en', data: phrases }, + }); }); it('should inline custom CSS into the when present', async () => { @@ -112,6 +115,31 @@ describe('koaExperienceSsr()', () => { expect(ctx.body).toContain('content: "<\\/style>"'); }); + // The regex matches the ``, so every end-tag variant + // the HTML parser accepts — uppercase, or whitespace before `>` — is defused the same way. + it.each(['', '', ''])( + 'should escape the `%s` end-tag variant in custom CSS', + async (variant) => { + ( + tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock + ).mockResolvedValueOnce({ + ...mockSignInExperience, + customCss: `body::before { content: "${variant}"; }`, + }); + + const ctx = { + ...baseCtx, + path: '/', + body: ``, + }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + + // ``) is intact. + expect(ctx.body).toContain(`content: "${variant.replace(/<\/(style)/i, '<\\/$1')}"`); + expect(ctx.body).not.toMatch(/content: "<\/(?:style|STYLE)/); + } + ); + it('should escape characters in the SSR JSON so embedded data cannot break out of the ' } } From 4c3fbdb33e80abdf961c7dd777c1e35dc6c7b7a9 Mon Sep 17 00:00:00 2001 From: Mingqing Ye Date: Thu, 4 Jun 2026 10:30:33 +0800 Subject: [PATCH 08/11] test(core): cover custom CSS head inlining in experience SSR integration test --- .../experience/server-side-rendering.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts b/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts index e41ef12b3550..5b45b569cc70 100644 --- a/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts +++ b/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts @@ -1,6 +1,7 @@ import { demoAppApplicationId, fullSignInExperienceGuard } from '@logto/schemas'; import { z } from 'zod'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; import { demoAppUrl } from '#src/constants.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; import ExpectExperience from '#src/ui-helpers/expect-experience.js'; @@ -108,4 +109,39 @@ describe('server-side rendering', () => { // Check network requests await expectTraceNotToHaveWellKnownEndpoints(); }); + + describe('custom CSS inlining', () => { + // A marker the server adds (`