Skip to content

Commit 69fb670

Browse files
authored
refactor(component): split vdom and hydration test suites (#11031)
1 parent 4eecdac commit 69fb670

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+5579
-5227
lines changed
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { invariant } from './invariant.ts'
2+
import { processStyle, createStyleManager, normalizeCssValue } from './style/index.ts'
3+
import type { ElementProps } from './jsx.ts'
4+
5+
const SVG_NS = 'http://www.w3.org/2000/svg'
6+
const XLINK_NS = 'http://www.w3.org/1999/xlink'
7+
const XML_NS = 'http://www.w3.org/XML/1998/namespace'
8+
9+
// global so all roots share it
10+
let styleCache = new Map<string, { selector: string; css: string }>()
11+
let styleManager =
12+
typeof window !== 'undefined'
13+
? createStyleManager()
14+
: (null as unknown as ReturnType<typeof createStyleManager>)
15+
16+
export function cleanupCssProps(props: ElementProps | undefined) {
17+
if (!props?.css) return
18+
let { selector } = processStyle(props.css, styleCache)
19+
if (selector) {
20+
styleManager.remove(selector)
21+
}
22+
}
23+
24+
function diffCssProp(curr: ElementProps, next: ElementProps, dom: Element) {
25+
let prevSelector = curr.css ? processStyle(curr.css, styleCache).selector : ''
26+
let { selector: nextSelector, css } = next.css
27+
? processStyle(next.css, styleCache)
28+
: { selector: '', css: '' }
29+
30+
if (prevSelector === nextSelector) return
31+
32+
// Remove old CSS
33+
if (prevSelector) {
34+
dom.removeAttribute('data-css')
35+
styleManager.remove(prevSelector)
36+
}
37+
38+
// Add new CSS
39+
if (css && nextSelector) {
40+
dom.setAttribute('data-css', nextSelector)
41+
styleManager.insert(nextSelector, css)
42+
}
43+
}
44+
45+
// Preact excludes certain attributes from the property path due to browser quirks
46+
const ATTRIBUTE_FALLBACK_NAMES = new Set([
47+
'width',
48+
'height',
49+
'href',
50+
'list',
51+
'form',
52+
'tabIndex',
53+
'download',
54+
'rowSpan',
55+
'colSpan',
56+
'role',
57+
'popover',
58+
])
59+
60+
// Determine if we should use the property path for a given name.
61+
// Also acts as a type guard to allow bracket assignment without casts.
62+
function canUseProperty(
63+
dom: Element,
64+
name: string,
65+
isSvg: boolean,
66+
): dom is Element & Record<string, unknown> {
67+
if (isSvg) return false
68+
if (ATTRIBUTE_FALLBACK_NAMES.has(name)) return false
69+
return name in dom
70+
}
71+
72+
function isFrameworkProp(name: string): boolean {
73+
return (
74+
name === 'children' ||
75+
name === 'key' ||
76+
name === 'on' ||
77+
name === 'css' ||
78+
name === 'setup' ||
79+
name === 'connect' ||
80+
name === 'animate' ||
81+
name === 'innerHTML'
82+
)
83+
}
84+
85+
// TODO: would rather actually diff el.style object directly instead of writing
86+
// to the style attribute
87+
function serializeStyleObject(style: Record<string, unknown>): string {
88+
let parts: string[] = []
89+
for (let [key, value] of Object.entries(style)) {
90+
if (value == null) continue
91+
if (typeof value === 'boolean') continue
92+
if (typeof value === 'number' && !Number.isFinite(value)) continue
93+
94+
let cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
95+
96+
let cssValue = Array.isArray(value)
97+
? (value as unknown[]).join(', ')
98+
: normalizeCssValue(key, value)
99+
100+
parts.push(`${cssKey}: ${cssValue};`)
101+
}
102+
return parts.join(' ')
103+
}
104+
105+
function normalizePropName(name: string, isSvg: boolean): { ns?: string; attr: string } {
106+
// aria-/data- pass through
107+
if (name.startsWith('aria-') || name.startsWith('data-')) return { attr: name }
108+
109+
// DOM property -> HTML mappings
110+
if (!isSvg) {
111+
if (name === 'className') return { attr: 'class' }
112+
if (name === 'htmlFor') return { attr: 'for' }
113+
if (name === 'tabIndex') return { attr: 'tabindex' }
114+
if (name === 'acceptCharset') return { attr: 'accept-charset' }
115+
if (name === 'httpEquiv') return { attr: 'http-equiv' }
116+
return { attr: name.toLowerCase() }
117+
}
118+
119+
// SVG namespaced specials
120+
if (name === 'xlinkHref') return { ns: XLINK_NS, attr: 'xlink:href' }
121+
if (name === 'xmlLang') return { ns: XML_NS, attr: 'xml:lang' }
122+
if (name === 'xmlSpace') return { ns: XML_NS, attr: 'xml:space' }
123+
124+
// SVG preserved-case exceptions
125+
if (
126+
name === 'viewBox' ||
127+
name === 'preserveAspectRatio' ||
128+
name === 'gradientUnits' ||
129+
name === 'gradientTransform' ||
130+
name === 'patternUnits' ||
131+
name === 'patternTransform' ||
132+
name === 'clipPathUnits' ||
133+
name === 'maskUnits' ||
134+
name === 'maskContentUnits'
135+
) {
136+
return { attr: name }
137+
}
138+
139+
// General SVG: kebab-case
140+
return { attr: camelToKebab(name) }
141+
}
142+
143+
function camelToKebab(input: string): string {
144+
return input
145+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
146+
.replace(/_/g, '-')
147+
.toLowerCase()
148+
}
149+
150+
export function diffHostProps(curr: ElementProps, next: ElementProps, dom: Element) {
151+
let isSvg = dom.namespaceURI === SVG_NS
152+
153+
if (next.css || curr.css) {
154+
diffCssProp(curr, next, dom)
155+
}
156+
157+
// Removals
158+
for (let name in curr) {
159+
if (isFrameworkProp(name)) continue
160+
if (!(name in next) || next[name] == null) {
161+
// Prefer property clearing when applicable (align with Preact)
162+
if (canUseProperty(dom, name, isSvg)) {
163+
try {
164+
dom[name] = ''
165+
continue
166+
} catch {}
167+
}
168+
169+
let { ns, attr } = normalizePropName(name, isSvg)
170+
if (ns) dom.removeAttributeNS(ns, attr)
171+
else dom.removeAttribute(attr)
172+
}
173+
}
174+
175+
// Additions/updates
176+
for (let name in next) {
177+
if (isFrameworkProp(name)) continue
178+
let nextValue = next[name]
179+
if (nextValue == null) continue
180+
let prevValue = curr[name]
181+
if (prevValue !== nextValue) {
182+
let { ns, attr } = normalizePropName(name, isSvg)
183+
184+
// Object style: serialize to attribute for now
185+
if (
186+
attr === 'style' &&
187+
typeof nextValue === 'object' &&
188+
nextValue &&
189+
!Array.isArray(nextValue)
190+
) {
191+
dom.setAttribute('style', serializeStyleObject(nextValue))
192+
continue
193+
}
194+
195+
// Prefer property assignment when possible (HTML only, not SVG)
196+
if (canUseProperty(dom, name, isSvg)) {
197+
try {
198+
dom[name] = nextValue == null ? '' : nextValue
199+
continue
200+
} catch {}
201+
}
202+
203+
// Attribute path
204+
if (typeof nextValue === 'function') {
205+
// Never serialize functions as attribute values
206+
continue
207+
}
208+
209+
let isAriaOrData = name.startsWith('aria-') || name.startsWith('data-')
210+
if (nextValue != null && (nextValue !== false || isAriaOrData)) {
211+
// Special-case popover: true => presence only
212+
let attrValue = name === 'popover' && nextValue === true ? '' : String(nextValue)
213+
if (ns) dom.setAttributeNS(ns, attr, attrValue)
214+
else dom.setAttribute(attr, attrValue)
215+
} else {
216+
if (ns) dom.removeAttributeNS(ns, attr)
217+
else dom.removeAttribute(attr)
218+
}
219+
}
220+
}
221+
}
222+
223+
/**
224+
* Reset the global style state. For testing only - not exported from index.ts.
225+
*/
226+
export function resetStyleState() {
227+
styleCache.clear()
228+
invariant(
229+
typeof window !== 'undefined',
230+
'resetStyleState() is only available in a browser environment',
231+
)
232+
styleManager.dispose()
233+
styleManager = createStyleManager()
234+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export function logHydrationMismatch(...msg: any[]) {
2+
console.error('Hydration mismatch:', ...msg)
3+
}
4+
5+
export function skipComments(cursor: Node | null): Node | null {
6+
while (cursor && cursor.nodeType === Node.COMMENT_NODE) {
7+
cursor = cursor.nextSibling
8+
}
9+
return cursor
10+
}

0 commit comments

Comments
 (0)