Skip to content
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

fix: hydration mismatches by saving changed tokens #467

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
],
"engines": {
"node": ">=14.15.0",
"pnpm": "^7.0.0"
"pnpm": ">=7.0.0"
},
"// https://nodejs.org/dist/latest-v16.x/docs/api/corepack.html": "",
"packageManager": "pnpm@7.16.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/cdn/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"name": "@twind/cdn",
"path": "dist/cdn.esnext.js",
"brotli": true,
"limit": "16.6kb"
"limit": "16.8kb"
}
],
"dependencies": {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,21 @@
"name": "@twind/core",
"path": "dist/core.esnext.js",
"brotli": true,
"limit": "8.2kb"
"limit": "8.4kb"
},
{
"name": "@twind/core (setup)",
"path": "dist/core.esnext.js",
"import": "{ setup }",
"brotli": true,
"limit": "5.65kb"
"limit": "5.8kb"
},
{
"name": "@twind/core (twind + cssom)",
"path": "dist/core.esnext.js",
"import": "{ twind, cssom }",
"brotli": true,
"limit": "5kb"
"limit": "5.2kb"
}
],
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function setup<
export function setup<Theme extends BaseTheme = BaseTheme, SheetTarget = unknown>(
config: TwindConfig<any> | TwindUserConfig<any> = {},
sheet: Sheet<SheetTarget> | SheetFactory<SheetTarget> = getSheet as SheetFactory<SheetTarget>,
target?: HTMLElement,
target?: HTMLElement | false,
): Twind<Theme, SheetTarget> {
active?.destroy()

Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/sheets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function stringify(target: unknown): string {

function resume(
this: Sheet,
addClassName: (className: string) => void,
addClassName: (tokens: string, className?: string) => void,
insert: (cssText: string, rule: SheetRule) => void,
) {
// hydration from SSR sheet
Expand All @@ -217,17 +217,14 @@ function resume(
// RE has global flag — reset index to get the first match as well
RE.lastIndex = 0

// 1. start with a fresh sheet
this.clear()

// 2. add all existing class attributes to the token/className cache
// 1. add all existing class attributes to the token/className cache
if (typeof document != 'undefined') {
for (const el of document.querySelectorAll('[class]')) {
addClassName(el.getAttribute('class') as string)
}
}

// 3. parse SSR styles
// 2. parse SSR styles
let lastMatch: RegExpExecArray | null | undefined

while (
Expand Down
17 changes: 12 additions & 5 deletions packages/core/src/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ export function inline(markup: string, options: InlineOptions['tw'] | InlineOpti
const { tw = tw$, minify = identity } =
typeof options == 'function' ? ({ tw: options } as InlineOptions) : options

const { html, css } = extract(markup, tw)
const { html, css, json } = extract(markup, tw)

// inject as last element into the head
return html.replace('</head>', `<style data-twind>${minify(css, html)}</style></head>`)
return html
.replace('</head>', `<style data-twind>${minify(css, html)}</style></head>`)
.replace('</body>', `<script type="application/json" data-twind-cache>${json}</script></body>`)
}

/**
Expand All @@ -92,6 +94,9 @@ export interface ExtractResult {

/** The generated CSS */
css: string

/** The json state necessary for hydration on the browser */
json: string
}

/**
Expand Down Expand Up @@ -121,10 +126,12 @@ export interface ExtractResult {
* import { tw } from './custom/twind/instance'
*
* function render() {
* const { html, css } = extract(renderApp(), tw)
* const { html, css, json } = extract(renderApp(), tw)
*
* // inject as last element into the head
* return html.replace('</head>', `<style data-twind>${css}</style></head>`)
* return html
* .replace('</head>', `<style data-twind>${css}</style></head>`)
* .replace('</body>', `<script type="application/json" data-twind-cache>${json}</script></body>`)
* }
* ```
*
Expand All @@ -136,7 +143,7 @@ export interface ExtractResult {
export function extract(html: string, tw: Twind<any, any> = tw$): ExtractResult {
const restore = tw.snapshot()

const result = { html: consume(html, tw), css: stringify(tw.target) }
const result = { html: consume(html, tw), css: stringify(tw.target), json: tw.cache.toString() }

restore()

Expand Down
27 changes: 11 additions & 16 deletions packages/core/src/tests/observe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { assert, test } from 'vitest'
import presetTailwind from '@twind/preset-tailwind'
import { twind, virtual, observe, inline, getSheet } from '..'
import { getSheet, inline, observe, twind, virtual } from '..'

test('observe in browser', () => {
document.documentElement.innerHTML = `
Expand Down Expand Up @@ -60,9 +60,7 @@ test('observe in browser', () => {
// ensure the observer is disconnected on destroy
tw.destroy()
// the stylesheet is emptied
assert.lengthOf(tw.target, 0)

// attributes are no longer modified
assert.lengthOf(tw.target, 0) // attributes are no longer modified
;(document.querySelector('main') as Element).className = '~(text-3xl bg-gray-100)'
// the stylesheet is emptied
assert.lengthOf(tw.target, 0)
Expand Down Expand Up @@ -148,21 +146,18 @@ test('hydratable from SSR sheet', () => {
),
)

// Make sure the sheet hydrated correctly, i.e. and calling the same tokens does not change the sheet
tw('h-screen bg-purple-400 flex items-center justify-center')
tw(
'font-bold /* you can even use inline comments */ text-(center 5xl white sm:gray-800 md:pink-700)',
)

console.debug(Array.from((tw.target as HTMLStyleElement).childNodes, (node) => node.textContent))

assert.deepEqual(
Array.from((tw.target as HTMLStyleElement).childNodes, (node) => node.textContent),
[
'.text-white{--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}',
'.\\!block{display:block !important}',
'.flex{display:flex}',
'.h-screen{height:100vh}',
'.bg-purple-400{--tw-bg-opacity:1;background-color:rgba(192,132,252,var(--tw-bg-opacity))}',
'.text-5xl{font-size:3rem;line-height:1}',
'.font-bold{font-weight:700}',
'.items-center{align-items:center}',
'.justify-center{justify-content:center}',
'.text-center{text-align:center}',
'@media (min-width:640px){.sm\\:text-gray-800{--tw-text-opacity:1;color:rgba(31,41,55,var(--tw-text-opacity))}}',
'@media (min-width:768px){.md\\:text-pink-700{--tw-text-opacity:1;color:rgba(190,24,93,var(--tw-text-opacity))}}',
'/*!dbgidc,t,text-white*/.text-white{--tw-text-opacity:1;color:rgba(255,255,255,var(--tw-text-opacity))}/*!dbgidc,v,!block*/.\\!block{display:block !important}/*!dbgidc,v,flex*/.flex{display:flex}/*!dbgidc,v,h-screen*/.h-screen{height:100vh}/*!dbgidc,w,bg-purple-400*/.bg-purple-400{--tw-bg-opacity:1;background-color:rgba(192,132,252,var(--tw-bg-opacity))}/*!dbgidc,w,text-5xl*/.text-5xl{font-size:3rem;line-height:1}/*!dbgidc,y,font-bold*/.font-bold{font-weight:700}/*!dbgidc,y,items-center*/.items-center{align-items:center}/*!dbgidc,y,justify-center*/.justify-center{justify-content:center}/*!dbgidc,y,text-center*/.text-center{text-align:center}/*!eupiio,t,sm:text-gray-800*/@media (min-width:640px){.sm\\:text-gray-800{--tw-text-opacity:1;color:rgba(31,41,55,var(--tw-text-opacity))}}/*!ex7ev4,t,md:text-pink-700*/@media (min-width:768px){.md\\:text-pink-700{--tw-text-opacity:1;color:rgba(190,24,93,var(--tw-text-opacity))}}',
],
)
})
73 changes: 64 additions & 9 deletions packages/core/src/twind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type {
BaseTheme,
ExtractThemes,
Preset,
Twind,
Sheet,
Twind,
TwindConfig,
TwindRule,
TwindUserConfig,
Expand All @@ -19,6 +19,60 @@ import { defineConfig } from './define-config'
import { asArray } from './utils'
import { serialize } from './internal/serialize'
import { Layer } from './internal/precedence'
import { hash } from './utils'

export function twCache() {
let serialized = '[]'

if (typeof document !== 'undefined') {
const element: HTMLElement | null = document.querySelector('script[data-twind-cache=""]')

if (element) {
element.dataset.twindCache = 'restored'
serialized = element.innerText

console.log(serialized)
}
}

let cache = new Map<string, string>(Object.entries(JSON.parse(serialized)))

return {
get(key: string) {
return cache.get(hash(key))
},

set(key: string, value: string) {
cache.set(hash(key), value)
},

size() {
return cache.size
},

clear() {
cache = new Map()
},

snapshot() {
const cache$ = new Map(cache)

return () => {
cache = cache$
}
},

toString() {
return JSON.stringify(
Object.fromEntries(
Array.from(cache.entries()).filter(([key, value]) => key !== hash(value)),
),
)
},
}
}

type Cache = ReturnType<typeof twCache>

/**
* @group Runtime
Expand All @@ -45,7 +99,7 @@ export function twind(userConfig: TwindConfig<any> | TwindUserConfig<any>, sheet
const context = createContext(config)

// Map of tokens to generated className
let cache = new Map<string, string>()
const cache = twCache()

// An array of precedence by index within the sheet
// always sorted
Expand All @@ -56,9 +110,8 @@ export function twind(userConfig: TwindConfig<any> | TwindUserConfig<any>, sheet
let insertedRules = new Set<string>()

sheet.resume(
(className) => cache.set(className, className),
(tokens, className) => cache.set(tokens, className ?? tokens),
(cssText, rule) => {
sheet.insert(cssText, sortedPrecedences.length, rule)
sortedPrecedences.push(rule)
insertedRules.add(cssText)
},
Expand Down Expand Up @@ -89,7 +142,7 @@ export function twind(userConfig: TwindConfig<any> | TwindUserConfig<any>, sheet

return Object.defineProperties(
function tw(tokens) {
if (!cache.size) {
if (!cache.size()) {
for (let preflight of asArray(config.preflight)) {
if (typeof preflight == 'function') {
preflight = preflight(context)
Expand Down Expand Up @@ -118,7 +171,7 @@ export function twind(userConfig: TwindConfig<any> | TwindUserConfig<any>, sheet
className = [...classNames].filter(Boolean).join(' ')

// Remember the generated class name
cache.set(tokens, className).set(className, className)
cache.set(tokens, className)
}

return className
Expand All @@ -132,26 +185,28 @@ export function twind(userConfig: TwindConfig<any> | TwindUserConfig<any>, sheet

config,

cache,

snapshot() {
const restoreSheet = sheet.snapshot()
const restoreCache = cache.snapshot()
const insertedRules$ = new Set(insertedRules)
const cache$ = new Map(cache)
const sortedPrecedences$ = [...sortedPrecedences]

return () => {
restoreSheet()
restoreCache()

insertedRules = insertedRules$
cache = cache$
sortedPrecedences = sortedPrecedences$
}
},

clear() {
sheet.clear()
cache.clear()

insertedRules = new Set()
cache = new Map()
sortedPrecedences = []
},

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface Twind<Theme extends BaseTheme = BaseTheme, Target = unknown> {

readonly config: TwindConfig<Theme>

readonly cache: Map<string, string>

snapshot(): RestoreSnapshot

/** Clears all CSS rules from the sheet. */
Expand Down Expand Up @@ -282,7 +284,7 @@ export interface Sheet<Target = unknown> {
clear(): void
destroy(): void
resume(
addClassName: (className: string) => void,
addClassName: (tokens: string, className?: string) => void,
insert: (cssText: string, rule: SheetRule) => void,
): void
}
Expand Down
11 changes: 9 additions & 2 deletions packages/with-gatsby/src/gatsby-ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,23 @@ export function replaceRenderer({
setHeadComponents,
}: ReplaceRendererArgs): void {
const bodyHTML = renderToString(bodyComponent as ReactElement)
const { html, css } = extract(bodyHTML)
const { html, css, json } = extract(bodyHTML)

replaceBodyHTMLString(html)
setHeadComponents([
// <style data-twind>{css}</style>
createElement('style', {
'data-twind': true,
'data-twind': '',
dangerouslySetInnerHTML: {
__html: css,
},
}),
createElement('script', {
type: 'application/json',
'data-twind-cache': '',
dangerouslySetInnerHTML: {
__html: json,
},
}),
])
}
Loading