diff --git a/.claude/skills/add-test/SKILL.md b/.claude/skills/add-test/SKILL.md index f8c65e40..64c05607 100644 --- a/.claude/skills/add-test/SKILL.md +++ b/.claude/skills/add-test/SKILL.md @@ -41,6 +41,12 @@ Verify CSS → JSON compilation output structure. Test runtime style application on native platform. +> A `:root` custom property with exactly one declaration is inlined by the +> `inlineVariables` compiler pass and never reaches the runtime, so a test +> written that way passes with the runtime registry deleted. Declare it with +> `dynamicRootVariables` from `react-native-css/jest`. See the Testing section +> of `DEVELOPMENT.md`. + ## Steps 1. **Identify the feature**: What needs testing? Use `$ARGUMENTS` as the starting point. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fdb0f9c..6fddac9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,22 @@ jobs: - name: Run unit tests run: yarn test --maxWorkers=2 --coverage + # The babel plugin, the metro resolver and the compiler all join and compare + # file paths. A separator or drive-letter bug in any of them is invisible to a + # POSIX runner and breaks every Windows contributor, so the unit suite runs + # here too. Coverage is left to the ubuntu job; this one only needs to fail. + test-windows: + runs-on: windows-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run unit tests + run: yarn test --maxWorkers=2 + build-library: runs-on: ubuntu-latest steps: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4ce58467..aada4902 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -124,6 +124,49 @@ yarn example start:debug # Rebuild + start with debug logging - **Run specific suites:** `yarn test babel`, `yarn test compiler` - Ignore `ExperimentalWarning: VM Modules` warnings — expected with ESM support +### Custom properties in tests: use `dynamicRootVariables` + +The `inlineVariables` pass (`src/compiler/inline-variables.ts`) inlines a custom +property that has exactly **one declaration**. This: + +```css +:root { --my-var: #123456; } +.my-class { color: var(--my-var); } +``` + +compiles to `color: #123456` with **no root variable entry at all** — the +`var()` is gone before the runtime ever sees it. A test written that way +asserts the inliner, not the runtime, and it keeps passing with the runtime +variable registry deleted. + +Reading the property from more rules does not help: the pass counts +declarations, not uses. A second **declaration** is what keeps it dynamic — +which is why real stylesheets rarely hit this (a `.dark` override or a themed +media query is a second declaration) and hand-written test CSS usually does. + +When the subject of your test is the runtime, declare the property through the +helper, which emits a second guarded declaration to keep it dynamic: + +```ts +import { dynamicRootVariables, registerCSS } from "react-native-css/jest"; + +registerCSS(` + ${dynamicRootVariables({ "--my-var": "10px" })} + .my-class { width: var(--my-var); } +`); +``` + +Rendering the component is not enough to tell the two apart: the inlined literal +and the resolved variable produce the same style, so both forms render green. +Assert on the compiled stylesheet — a dynamic property has a `vr` entry and a +`var` descriptor in `d` — or verify by deleting the registry and watching the +test go red. `src/__tests__/native/dynamic-root-variables.test.tsx` pins both +the inliner and the helper. + +Compiling with `{ inlineVariables: false }` also keeps the property dynamic, but +it turns the pass off for the whole stylesheet and tests a configuration users +do not run. Reach for it only when the inliner itself is the subject. + ## Code Conventions - TypeScript throughout @@ -137,5 +180,6 @@ yarn example start:debug # Rebuild + start with debug logging - **No npm** — this repo uses Yarn workspaces; `npm install` will not work - **No rebuild watch** — use `yarn example start:build` to rebuild + start in one command - **Metro transformer / Babel plugin changes require full rebuild** — no fast refresh for these +- **A single `:root` declaration never reaches the runtime** — `inlineVariables` inlines it at compile time; use `dynamicRootVariables` in tests (see [Testing](#custom-properties-in-tests-use-dynamicrootvariables)) - **native-internal exists to break circular deps** — don't import directly from `native/` in CSS file outputs; use `native-internal/` - **Nested node_modules in example/** — can cause Metro issues; ensure dependency versions match root diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 760ede29..946cece0 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -1,6 +1,6 @@ import { compile } from "react-native-css/compiler"; -describe.skip("platform media queries", () => { +describe("platform media queries", () => { test("android", () => { const compiled = compile(` @media android and (min-width: 500px) { @@ -14,8 +14,8 @@ describe.skip("platform media queries", () => { "my-class", [ { - s: [1, 1], - d: [{ color: "#ff0000" }], + s: [2, 1], + d: [{ color: "#f00" }], m: [ [ "&", @@ -25,6 +25,7 @@ describe.skip("platform media queries", () => { ], ], ], + v: [["__rn-css-color", "#f00"]], }, ], ], @@ -45,15 +46,18 @@ describe.skip("platform media queries", () => { "my-class", [ { - s: [1, 1], - d: [{ color: "#ff0000" }], + s: [2, 1], + d: [{ color: "#f00" }], m: [ - "&", [ - ["=", "platform", "ios"], - [">=", "width", 500], + "&", + [ + ["=", "platform", "ios"], + [">=", "width", 500], + ], ], ], + v: [["__rn-css-color", "#f00"]], }, ], ], @@ -85,3 +89,54 @@ test("@media (hover: hover)", () => { ], }); }); + +// The runtime resolves this condition against the `colorScheme` observable +// (`src/native/conditions/media-query.ts`). `light-dark()` reaches the same +// condition, but the compiler synthesises it there — this covers the parse. +test("@media (prefers-color-scheme: dark)", () => { + const compiled = compile(` + @media (prefers-color-scheme: dark) { + .my-class { color: red; } + } + `); + + expect(compiled.stylesheet()).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + d: [{ color: "#f00" }], + m: [["=", "prefers-color-scheme", "dark"]], + v: [["__rn-css-color", "#f00"]], + }, + ], + ], + ], + }); +}); + +test("@media (prefers-color-scheme: light)", () => { + const compiled = compile(` + @media (prefers-color-scheme: light) { + .my-class { color: red; } + } + `); + + expect(compiled.stylesheet()).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + d: [{ color: "#f00" }], + m: [["=", "prefers-color-scheme", "light"]], + v: [["__rn-css-color", "#f00"]], + }, + ], + ], + ], + }); +}); diff --git a/src/__tests__/metro/global-class-name-polyfill.test.ts b/src/__tests__/metro/global-class-name-polyfill.test.ts new file mode 100644 index 00000000..7c471972 --- /dev/null +++ b/src/__tests__/metro/global-class-name-polyfill.test.ts @@ -0,0 +1,196 @@ +import { resolve, sep } from "node:path"; + +import type { MetroConfig } from "metro-config"; +import type { + CustomResolutionContext, + CustomResolver, + Resolution, +} from "metro-resolver"; + +import { withReactNativeCSS } from "../../metro"; + +// `globalClassNamePolyfill` is the gate that decides whether the resolvers run +// at all (`src/metro/index.ts`). The resolvers themselves are covered +// separately; this covers the dispatch into them. + +// The resolver reads exactly two fields off the context. Building the rest of +// `ResolutionContext` would be forty fields of metro internals that no code +// path under test touches, so the fixture is bridged once, here. +function makeContext( + originModulePath: string, + resolveRequest: CustomResolver, +): CustomResolutionContext { + return { + originModulePath, + resolveRequest, + } as unknown as CustomResolutionContext; +} + +function makeRecorder(): { calls: string[]; resolver: CustomResolver } { + const calls: string[] = []; + + const resolver: CustomResolver = (_context, moduleName): Resolution => { + calls.push(moduleName); + return { + type: "sourceFile", + filePath: resolve("/app/node_modules", moduleName, "index.js"), + }; + }; + + return { calls, resolver }; +} + +function makeConfig( + options?: Parameters[1], +): MetroConfig { + return withReactNativeCSS( + {}, + { disableTypeScriptGeneration: true, ...options }, + ); +} + +function resolveThrough( + config: MetroConfig, + moduleName: string, + platform: string | null, + resolver: CustomResolver, +): Resolution { + const resolveRequest = config.resolver?.resolveRequest; + + if (!resolveRequest) { + throw new Error("withReactNativeCSS did not install a resolveRequest"); + } + + return resolveRequest( + makeContext(resolve("/app/index.js"), resolver), + moduleName, + platform, + ); +} + +describe("globalClassNamePolyfill", () => { + test("is off by default, so react-native resolves untouched", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig(), + "react-native", + "ios", + resolver, + ); + + // The parent resolver is asked once, for the module that was requested. + expect(calls).toStrictEqual(["react-native"]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve("/app/node_modules/react-native/index.js"), + }); + }); + + test("routes react-native to the components barrel when on", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig({ globalClassNamePolyfill: true }), + "react-native", + "ios", + resolver, + ); + + expect(calls).toStrictEqual([ + "react-native", + "react-native-css/components", + ]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve( + "/app/node_modules/react-native-css/components/index.js", + ), + }); + }); + + test("dispatches to the web resolver on the web platform when on", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig({ globalClassNamePolyfill: true }), + "react-native-web/dist/exports/View", + "web", + resolver, + ); + + // The native resolver keys on the module name and would not have rewritten + // this one; the web resolver keys on the resolved react-native-web path. + expect(calls).toStrictEqual([ + "react-native-web/dist/exports/View", + "react-native-css/components/View", + ]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve( + "/app/node_modules/react-native-css/components/View/index.js", + ), + }); + }); + + test("leaves the same web module alone when off", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig(), + "react-native-web/dist/exports/View", + "web", + resolver, + ); + + expect(calls).toStrictEqual(["react-native-web/dist/exports/View"]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve( + "/app/node_modules/react-native-web/dist/exports/View/index.js", + ), + }); + }); + + test.each([true, false])( + "short-circuits the metro override without consulting the parent (polyfill: %s)", + (globalClassNamePolyfill) => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig({ globalClassNamePolyfill }), + "react-native-css-metro-override", + "ios", + resolver, + ); + + expect(calls).toStrictEqual([]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: expect.stringContaining(`${sep}override.`), + }); + }, + ); + + test("prefers an existing resolveRequest over the one on the context", () => { + const existing = makeRecorder(); + const fromContext = makeRecorder(); + + const config = withReactNativeCSS( + { resolver: { resolveRequest: existing.resolver } }, + { disableTypeScriptGeneration: true, globalClassNamePolyfill: true }, + ); + + resolveThrough(config, "react-native", "ios", fromContext.resolver); + + expect(existing.calls).toStrictEqual([ + "react-native", + "react-native-css/components", + ]); + expect(fromContext.calls).toStrictEqual([]); + }); + + test("appends the css source extension regardless of the gate", () => { + expect(makeConfig().resolver?.sourceExts).toStrictEqual(["css"]); + }); +}); diff --git a/src/__tests__/native/dynamic-root-variables.test.tsx b/src/__tests__/native/dynamic-root-variables.test.tsx new file mode 100644 index 00000000..25168e71 --- /dev/null +++ b/src/__tests__/native/dynamic-root-variables.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; +import { View } from "react-native-css/components/View"; +import { + dynamicRootVariables, + registerCSS, + testID, +} from "react-native-css/jest"; + +// `inlineVariables` (`src/compiler/inline-variables.ts`) inlines a custom +// property that has exactly one declaration. A `:root` test written that way +// asserts the inliner and passes with the runtime variable registry deleted, so +// it proves nothing about the runtime. `dynamicRootVariables` emits a second +// declaration to keep the property dynamic. +// +// The first tests pin the inliner's behaviour itself: if it stops inlining, or +// starts keying on use count instead of declaration count, they fail and the +// helper can be retired. + +test("a single :root declaration is inlined before the runtime sees it", () => { + const stylesheet = compile(` + :root { --my-var: #123456; } + .my-class { color: var(--my-var); } + `).stylesheet(); + + // No root-variable entry at all: the runtime registry is never reached. + expect(stylesheet.vr).toBeUndefined(); + expect(stylesheet.s).toStrictEqual([ + [ + "my-class", + [ + { + s: [1, 1], + d: [{ color: "#123456" }], + v: [["__rn-css-color", "#123456"]], + }, + ], + ], + ]); +}); + +test("reading a single declaration many times does not save it", () => { + // The inliner keys on declaration count, not use count, so spreading the + // `var()` across rules is not a way out of the trap. + const stylesheet = compile(` + :root { --my-var: #123456; } + .a { color: var(--my-var); } + .b { color: var(--my-var); } + `).stylesheet(); + + expect(stylesheet.vr).toBeUndefined(); +}); + +test("turning the inliner off is the other way to keep it dynamic", () => { + // Supported, but it applies to the whole stylesheet and is not the + // configuration users compile under, so tests prefer a second declaration. + const stylesheet = compile( + ` + :root { --my-var: #123456; } + .my-class { color: var(--my-var); } + `, + { inlineVariables: false }, + ).stylesheet(); + + expect(stylesheet.vr).toStrictEqual([["my-var", [["#123456"]]]]); +}); + +test("dynamicRootVariables keeps the property dynamic", () => { + const stylesheet = compile(` + ${dynamicRootVariables({ "--my-var": "#123456" })} + .my-class { color: var(--my-var); } + `).stylesheet(); + + // The declaration reaches the runtime registry, and the style is a `var` + // descriptor the runtime has to resolve rather than a folded literal. + expect(stylesheet.vr).toStrictEqual([ + ["my-var", [["#123456", [[">=", "width", 999999]]], ["#123456"]]], + ]); + expect(stylesheet.s?.[0]?.[1]).toStrictEqual([ + { + s: [4, 1], + d: [[[{}, "var", "my-var", 1], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "my-var", 1]]], + }, + ]); +}); + +test("dynamicRootVariables resolves to the declared value at runtime", () => { + registerCSS(` + ${dynamicRootVariables({ "--my-var": "10px" })} + .my-class { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("dynamicRootVariables accepts a name written without the -- prefix", () => { + registerCSS(` + ${dynamicRootVariables({ "my-var": "10px" })} + .my-class { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("dynamicRootVariables declares every property it is given", () => { + registerCSS(` + ${dynamicRootVariables({ "--width": "10px", "--height": "20px" })} + .my-class { width: var(--width); height: var(--height); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + width: 10, + height: 20, + }); +}); diff --git a/src/__tests__/native/media-query-condition.test.ts b/src/__tests__/native/media-query-condition.test.ts new file mode 100644 index 00000000..2ef1f1b5 --- /dev/null +++ b/src/__tests__/native/media-query-condition.test.ts @@ -0,0 +1,65 @@ +import type { MediaCondition } from "react-native-css/compiler"; + +import { testMediaQuery } from "../../native/conditions/media-query"; +import { colorScheme, type Getter } from "../../native/reactivity"; + +// `testMediaQuery` is the runtime half of a `@media` rule: the compiler emits +// the condition array, this evaluates it. Reaching it through a rendered +// component also exercises the collection and the style resolver, so a +// condition that is evaluated wrongly can still produce the right style. These +// feed the condition in directly. +const get: Getter = (observable) => observable.get(); + +const prefersDark: MediaCondition = ["=", "prefers-color-scheme", "dark"]; +const prefersLight: MediaCondition = ["=", "prefers-color-scheme", "light"]; + +const dark: MediaCondition[] = [prefersDark]; +const light: MediaCondition[] = [prefersLight]; + +test("prefers-color-scheme matches the current colorScheme", () => { + colorScheme.set("dark"); + expect(testMediaQuery(dark, get)).toBe(true); + expect(testMediaQuery(light, get)).toBe(false); + + colorScheme.set("light"); + expect(testMediaQuery(dark, get)).toBe(false); + expect(testMediaQuery(light, get)).toBe(true); +}); + +// `Appearance.getColorScheme()` returns null whenever the OS reports +// `unspecified` or the native module is absent, so this is a reachable +// production state and not a test-harness artifact. MQ5 resolves it to `light`: +// "light indicates that the user has expressed the preference for a light +// theme, or has not expressed an active preference". The rest of the library +// already assumes that — `light-dark()` compiles to a light base rule with the +// dark value behind `prefers-color-scheme: dark`, `colorScheme.get()` on native +// ends in `?? "light"`, and react-native-web's `getColorScheme()` reads the +// dark media query and answers "light" when it does not match. +test("prefers-color-scheme is light when no preference is set", () => { + colorScheme.set(null); + expect(testMediaQuery(dark, get)).toBe(false); + expect(testMediaQuery(light, get)).toBe(true); +}); + +// The fallback picks a value for the comparison; it does not turn the condition +// into a two-way branch. MQ5 requires an unrecognised value to be false rather +// than aliasing to the other one. +test("prefers-color-scheme does not match a value outside the two it defines", () => { + const nonsense: MediaCondition[] = [ + ["=", "prefers-color-scheme", "no-preference"], + ]; + + colorScheme.set(null); + expect(testMediaQuery(nonsense, get)).toBe(false); + + colorScheme.set("dark"); + expect(testMediaQuery(nonsense, get)).toBe(false); +}); + +test("prefers-color-scheme negates and combines like any other condition", () => { + colorScheme.set("dark"); + expect(testMediaQuery([["!", prefersDark]], get)).toBe(false); + expect(testMediaQuery([["!", prefersLight]], get)).toBe(true); + expect(testMediaQuery([["&", [prefersDark, prefersLight]]], get)).toBe(false); + expect(testMediaQuery([["|", [prefersDark, prefersLight]]], get)).toBe(true); +}); diff --git a/src/__tests__/web/color-scheme.test.ts b/src/__tests__/web/color-scheme.test.ts new file mode 100644 index 00000000..21428eed --- /dev/null +++ b/src/__tests__/web/color-scheme.test.ts @@ -0,0 +1,34 @@ +import { Appearance } from "react-native"; + +import { colorScheme } from "../../web/api"; + +// The web plane imports `Appearance` from "react-native", and the bundler +// substitutes react-native-web. TypeScript resolves react-native's `.d.ts` +// either way, so it sees a full `Appearance` and the substitution is invisible +// to `yarn typecheck` — only running the web module against react-native-web +// can observe what the web plane actually gets. Babel hoists this above the +// imports above. +jest.mock("react-native", () => + jest.requireActual>("react-native-web"), +); + +test("react-native-web's Appearance exposes no setColorScheme", () => { + // The upstream constraint the web plane is written against. If + // react-native-web grows a setter, this fails and `colorScheme.set` can + // forward to it. + expect(typeof Appearance.getColorScheme).toBe("function"); + expect(typeof Appearance.addChangeListener).toBe("function"); + expect(Appearance).not.toHaveProperty("setColorScheme"); +}); + +test("colorScheme.get reads the browser preference", () => { + expect(colorScheme.get()).toBe(Appearance.getColorScheme()); +}); + +test("colorScheme.set reports that the web plane cannot override the scheme", () => { + // Not a TypeError from calling through to a member that does not exist, and + // not a silent no-op either. + expect(() => { + colorScheme.set("dark"); + }).toThrow(/not supported on web.*browser owns the color scheme/i); +}); diff --git a/src/jest/index.ts b/src/jest/index.ts index cc125390..ce000130 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -55,6 +55,48 @@ export function registerCSS( return compiled; } +// Wide enough that no test viewport matches it. Both declarations carry the +// same value, so the resolved value does not depend on that staying true. +const NEVER_MATCHES = "(min-width: 999999px)"; + +/** + * Declares `:root` custom properties in a form that reaches the runtime + * variable registry. + * + * `inlineVariables` (`src/compiler/inline-variables.ts`) inlines a custom + * property that has exactly one **declaration**, so `:root { --my-var: red }` + * compiles to a literal with no root variable entry at all. Use count does not + * save it — one declaration read from ten rules is still inlined. A test + * written that way asserts the inliner and passes with the runtime registry + * deleted. A second declaration keeps the property dynamic, so `var()` stays a + * descriptor the runtime has to resolve. + * + * Use this whenever a test's subject is the runtime, not the inliner: + * + * ```ts + * registerCSS(` + * ${dynamicRootVariables({ "--my-var": "10px" })} + * .my-class { width: var(--my-var); } + * `); + * ``` + * + * Real stylesheets usually reach this shape on their own — a `.dark` override + * or a themed media query is a second declaration. Compiling with + * `{ inlineVariables: false }` also works, but it turns the pass off for the + * whole stylesheet and tests a configuration users do not run; prefer this. + */ +export function dynamicRootVariables( + variables: Record, +): string { + const declarations = Object.entries(variables) + .map(([name, value]) => { + return `${name.startsWith("--") ? name : `--${name}`}: ${value};`; + }) + .join(" "); + + return `:root { ${declarations} } @media ${NEVER_MATCHES} { :root { ${declarations} } }`; +} + export function compileWithAutoDebug( css: string, { diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..111bcf81 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -45,7 +45,15 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "platform": return value === "native" || value === Platform.OS; case "prefers-color-scheme": { - return value === get(colorScheme); + // `Appearance.getColorScheme()` answers null when the OS reports + // `unspecified` or the native module is absent. MQ5 resolves the absence + // of a preference to `light`, and the rest of the library already assumes + // that: `light-dark()` compiles to a light base rule with the dark value + // behind `prefers-color-scheme: dark`, and react-native-web reads the + // dark media query and answers "light" when it does not match. Comparing + // against the resolved scheme rather than branching keeps an unrecognised + // value false. + return value === (get(colorScheme) ?? "light"); } case "display-mode": return value === "native" || Platform.OS === value; diff --git a/src/web/api.tsx b/src/web/api.tsx index e43f800a..40ea9369 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -72,8 +72,17 @@ export const colorScheme: ColorScheme = { get() { return Appearance.getColorScheme(); }, - set(name) { - Appearance.setColorScheme(name); + set() { + // `Appearance` here is react-native-web's, which reads through to + // `matchMedia("(prefers-color-scheme: dark)")` and exposes no setter. The + // browser owns the color scheme on web and evaluates + // `@media (prefers-color-scheme)` itself, so unlike the native runtime + // there is no observable for an override to drive. Reporting that is the + // only honest option: returning silently would leave an in-app theme + // toggle broken with nothing to find. + throw new Error( + "colorScheme.set() is not supported on web: the browser owns the color scheme. Style the two schemes with @media (prefers-color-scheme) and let the browser pick.", + ); }, };