Knit UI
GitHub

Changelog

Generated from each package's changelog. Packages are versioned independently and published with Changesets; a release is one version PR that bumps everything affected together.

0.8.0

@knitui/core

  • minor Provider accepts backgroundColor for the full-screen layer it paints behind every route. It still defaults to the theme's $background; hosts that paint their own page background (a web page with CSS gradients or custom properties) can now pass "transparent" so that background is not covered.
  • <Provider> now registers the config it is handed as the active Tamagui config, and setConfig joins getConfig in the @knitui/core surface. Tamagui keeps one config per process and createTamagui registers its result as a side effect of being called, but <TamaguiProvider config={…}> does not register what it is passed — it only reads getCSS() and animations. So an app that built its own config with createTheme was in a two-writer race with the kit's built-in config (the config prop's default), and which one ended up resolving $tokens was decided by module evaluation order: - webpack evaluates the built-in config eagerly, so in a Next app the app's config could win during SSR and lose on the client — raw colour tokens resolved in the server HTML and silently stopped resolving after hydration. - Metro's inlineRequires defers that import to its first use, so any code that merely touched the built-in config (a diagnostic comparing it against getConfig(), say) evaluated it at that moment and clobbered the app's config mid-render. Only raw tokens were affected, which is what made it hard to spot: theme values ($background, $color10, $borderColor) resolve through the theme and both configs ship the same theme names, so the app looked correct while every custom $brandColor resolved to nothing. The provider is where a consumer states which config is theirs, so it is now where that becomes true; it also re-asserts on later renders, so a stray late createTamagui no longer poisons the session.

@knitui/components

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0 - @knitui/hooks@0.8.0 - @knitui/icons@0.8.0

@knitui/hooks

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0

@knitui/dates

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0 - @knitui/components@0.8.0 - @knitui/hooks@0.8.0

@knitui/graphics

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0 - @knitui/components@0.8.0

@knitui/mediaquery

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0 - @knitui/hooks@0.8.0

0.7.0

@knitui/core

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.

@knitui/components

  • minor VirtualList can page backwards and hold the reading position while it does VirtualList shipped with onEndReached only, which covers the ordinary infinite feed but not the list whose history grows at the HEAD — a chat thread loading older messages, a log tailing backwards. Two new props close that: - `onStartReached` (+ onStartReachedThreshold, default 0.5 viewports) — the mirror of onEndReached, measured from offset 0, with the same one-shot latch: it re-arms only once the list has scrolled back out of the threshold band, so a caller whose page lands while still near the top is asked once rather than every frame. Like onEndReached on a short list it fires on mount at rest, because the list genuinely IS at the start. - `maintainVisibleContentPosition` — holds the reading position across a layout change instead of letting the rows slide. Off by default: it takes over the scroll offset, which a list that only grows at the tail has no reason to hand over. Backwards pagination needs the second prop to be usable at all, because an insert at the head moves every row already on screen. With it on, a prepend has its inserted height added to the scroll offset in the same frame — pre-paint, in a layout effect, since a passive effect would show one frame of the content having dropped by a page's worth of height. The anchor is re-derived on every layout bump, because the inserted rows start on the ESTIMATE and each real measurement above the anchor moves it again; against a 25-row page, a few px of per-row error is a visible drift. It is released once nothing above the anchor can still move, or the moment the user's own gesture arrives — correcting through a live gesture would fight it. Two fixes fall out of the same work: - The size model mis-attributed a prepend. resizeLayoutState preserves measurements by INDEX, which is right for an append or a pop and wrong for an insert at the head: row 0's measured height stayed on row 0, now a different row, and every other measurement read off by the number of rows inserted. The list then reflowed for the entire first scroll back up, re-learning heights in slots that already claimed to know them. With a keyExtractor, measurements are now keyed by item rather than by slot, so a prepend carries each one to its row's new index — see the keyed size model in the same release. - `scrollToEnd` landed short on unmeasured content. The destination comes from the total height, and on a list opening deep into content it has never measured that total is mostly estimate — so hitting it once leaves the last rows short of the bottom by the accumulated error (25 chat bubbles a dozen px under their estimate is a third of a screen). Under maintainVisibleContentPosition a non-animated scrollToEnd now HOLDS the end and re-derives it as those rows measure. Animated calls stay one-shot: an animated scroll reports a stream of intermediate positions, and this cannot tell those apart from the user grabbing the list mid-flight. Both props need keyExtractor: a prepend is recognised by finding the previous head row at its new index (one key comparison, not a diff), and index keys carry no identity to recognise it by. scrollToIndex / scrollToOffset / scrollToTop drop any held anchor — an explicit destination outranks whatever the list was holding on to.
  • minor VirtualList: `keepMounted`, keyed identity, and cheaper re-renders. VirtualList gained a keepMounted prop — an escalating ladder for how much stays mounted beyond the live window, so rows can keep their own state (a half-typed input, a playing video, in-flight work) instead of losing it the moment they scroll out: - false (default, unchanged) — pure virtualization; leaving the window unmounts. - a number — keep at most that many already-seen rows outside the window, evicting the least-recently-visible first (a bounded LRU warm pool). - true — keep every row that has ever mounted; unbounded, but still lazy. - "all" — no windowing at all: every row in data is mounted immediately, whether or not it has been on screen. Mount cost is O(data), so keep it to lists you would have rendered with .map() anyway. Supplying a keyExtractor now makes the list's caches follow items instead of slots: measured heights live in a per-key cache, so a prepend, insert, removal, sort or filter moves each row's height (and its retained mount) with its item rather than shifting them onto the wrong row. Without a keyExtractor the index remains the identity, which is only correct for append/pop — as before. This subsumes the index-shifting prepend the backwards-pagination work added earlier in this release: a prepend is one shape of data change among many, so it now goes through the same keyed re-sync, and prependLayoutState is gone. maintainVisibleContentPosition still detects the prepend by key — it needs to know how much height went in at the head to absorb it into the scroll offset — it just no longer moves the measurements itself. Rows now sit behind two memo boundaries instead of one. The inner boundary depends only on { item, index, renderItem, extraData }, so a shifted row offset — which happens on every measurement in a variable-height list — or an inline ItemSeparatorComponent / styles.item literal re-renders only the cheap positioned wrapper and no longer re-runs your renderItem. styles.item is also identity- stabilised internally, so writing it inline is now free. Fixes a crash when data shrank below the current window (a filter, a reset, a page rollback): the mounted window is clamped to the data, where it previously indexed past the end of the array and handed renderItem an undefined item.
  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[59d065b] - @knitui/core@0.7.0 - @knitui/hooks@0.7.0 - @knitui/icons@0.7.0

@knitui/hooks

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[59d065b] - @knitui/core@0.7.0

@knitui/icons

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.

@knitui/emoji

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.

@knitui/dates

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[edcec97]
  • Updated dependencies[15a329c]
  • Updated dependencies[59d065b] - @knitui/components@0.7.0 - @knitui/core@0.7.0 - @knitui/hooks@0.7.0

@knitui/graphics

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[edcec97]
  • Updated dependencies[15a329c]
  • Updated dependencies[59d065b] - @knitui/components@0.7.0 - @knitui/core@0.7.0

@knitui/mediaquery

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[59d065b] - @knitui/core@0.7.0 - @knitui/hooks@0.7.0

0.6.1

@knitui/core

  • Keep the Tamagui module augmentation reachable from the built declarations. config/config.ts carries the declare module "@tamagui/core" augmentation that teaches Tamagui this kit's token and shorthand vocabulary (maw, the $token unions). Nothing else on the barrel imported that module, so once consumers resolve the package by its built index.d.ts the augmentation was unreachable and every shorthand silently disappeared. A type-only re-export pulls it back in without eagerly evaluating the config at module load.
  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.

@knitui/components

  • Fix NaN colour channels for an out-of-range hue. hsvaToRgbaObject picks one of six channel tuples by hue / 60 and never normalised the hue first, so a negative one indexed off the front of those tuples and produced NaN for red, green and blue. ColorPicker reaches this on every hue change — it passes the hue straight through as a plain number — which made convertHsvaTo serialise rgb(NaN, NaN, NaN). Hues now wrap by Euclidean remainder, so -60 resolves as 300 and 420 as 60. Colour _strings_ were never affected: a negative hue fails validation and resolves to black, as before.
  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.
  • Updated dependencies[ffc254e]
  • Updated dependencies[ffb4133]
  • Updated dependencies[caa2d7e] - @knitui/core@0.6.1 - @knitui/hooks@0.6.1 - @knitui/icons@0.6.1

@knitui/hooks

  • Stop useListState corrupting the list on an out-of-range index. reorder destructured the spliced-out item, so a from beyond the end inserted a literal undefined into the list. swap wrote undefined over a real row when either index was out of range, and setItemProp spread a missing row into a bare { [prop]: value } object masquerading as a T. All three now leave the list untouched when an index does not resolve.
  • Updated dependencies[ffc254e]
  • Updated dependencies[caa2d7e] - @knitui/core@0.6.1

@knitui/icons

  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.

@knitui/dates

  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.
  • Updated dependencies[487dce4]
  • Updated dependencies[ffc254e]
  • Updated dependencies[ffb4133]
  • Updated dependencies[caa2d7e] - @knitui/components@0.6.1 - @knitui/core@0.6.1 - @knitui/hooks@0.6.1

@knitui/graphics

  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.
  • Updated dependencies[487dce4]
  • Updated dependencies[ffc254e]
  • Updated dependencies[caa2d7e] - @knitui/components@0.6.1 - @knitui/core@0.6.1

@knitui/mediaquery

  • Updated dependencies[ffc254e]
  • Updated dependencies[ffb4133]
  • Updated dependencies[caa2d7e] - @knitui/core@0.6.1 - @knitui/hooks@0.6.1

0.6.0

@knitui/core

  • minor Cut the shipped theme suite from 440 themes to 22, and stop the mediaquery hooks re-parsing / re-rendering per frame ## @knitui/core — 418 generated themes removed config/themes.ts called createThemes(...) without a componentThemes argument, so it inherited Tamagui's defaultComponentThemes — a 19-entry map that is itself @deprecated upstream ("component themes are no longer recommended"). Those entries cross-multiply with every scheme × palette, so the kit shipped 2 × 11 × 20 = 440 themes, all built eagerly at module scope on every app start and again by the Tamagui babel/static compiler. Measured against the kit's real inputs (interpreter-only, node --jitless): | | themes | theme keys | resolved Variables | heap | build | | ------ | ------ | ---------- | --------------------- | -------- | ------- | | before | 440 | 37,708 | 37,708 (2,822 unique) | 2,513 KB | 12.1 ms | | after | 22 | 1,870 | 1,870 (1,010 unique) | 208 KB | 2.8 ms | componentThemes: false is set both in the stock config/themes.ts and in createTheme(), so a consumer-built config has the same theme set as the shipped one (24 names there — the stock 22 plus the brand alias in both schemes). ### BREAKING for anyone naming a component theme Themes named <scheme>[_<palette>]_<Component> no longer exist. If you reference one by name — <Theme name="light_Button">, a themes override keyed dark_gray_Card, or a createTheme({ themeBuilder: { componentThemes: … } }) extension — it will no longer resolve and the nearest parent theme is used instead. The removed names are the 20 Tamagui defaults (Button, Card, Checkbox, Input, ListItem, Progress, ProgressIndicator, RadioGroupItem, SelectItem, SelectTrigger, SliderThumb, SliderTrack, SliderTrackActive, Switch, SwitchThumb, TextArea, Tooltip, TooltipArrow, TooltipContent) × 22 scheme/palette combinations. To get them back, pass your own map: ``ts createTheme({ themeBuilder: { componentThemes: { Card: { template: "surface1" } } } }); ` ### @knitui/components — offsets that were implicit are now explicit Nine of the kit's styled() names collided with that default map and were silently receiving a template offset. Every one of them has been PINNED so the rendered colour is unchanged — verified value-by-value across light, dark, light_blue and dark_red (100 probes, 0 differences): | component | template | change | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | ListItem | surface1 | none — the frame paints nothing, and surface* leaves $color1…$color12 and $color alone | | Progress | surface1 | none — already explicit $colorN | | Button | surface3 | variant="default" border pinned $borderColor$color7 (the only variant reading $borderColor) | | Card | surface1 | frame + Card.Section border pinned to $color2 / $color5 | | Checkbox | surface2 | unchecked box pinned to $color3 / $color6 | | SliderTrack | inverse | ramp steps written pre-mirrored ($color3$color10; child SliderBar $color9$color4) | | SliderThumb | inverse | pre-mirrored ($color1$color12, $color9$color4, hover $color10$color3; child SliderLabelBubble too) | | SwitchThumb | inverse | child SwitchThumbIndicator pre-mirrored ($color5$color8, on $color9$color4). The thumb itself uses $white, a token, so it was never inverted | | Tooltip | inverse | frame $color9$color4, Tooltip.Text $color1$color12 | Two things worth flagging to whoever owns the visual design: 1. The four inverse collisions were **reversing the entire $color1…$color12 ramp** under those subtrees, not offsetting one step. So Tooltip was authored $color9 fill + $color1 label (the kit's canonical filled pairing) and actually painted step 4 + step 12; the slider's track/bar/thumb were likewise inverted. The pins preserve the shipped pixels, but they codify an appearance that came from a deprecated Tamagui default rather than a design decision. Un-mirroring them (back to $color9/$color1 etc.) is a deliberate follow-up. 2. **Descendants** of those nine frames no longer inherit the offset theme. A component placed inside a Card used to resolve $background one ramp step up ($color2); it now gets the page value ($color1). Only the nine frames' own painted surfaces are pinned — arbitrary user content inside them shifts by one step (or un-inverts, inside the Slider/Tooltip parts). config/OVER_MEDIA also moved to its own module (config/over-media.ts) so importing those five scrim literals no longer drags in the whole theme generation. No export surface change. ## @knitui/mediaquery — no per-render parsing, 3N listeners → 3 - **matchesQuery no longer re-parses on every call.** The native useMediaQuery evaluated its query on the render path, and a string query re-ran the full parser each time: for "(min-width: 768px) and (orientation: landscape)" that is ~6 regex executions plus ~10 allocations, per render, per hook instance — so a 50-row list did ~300 regex ops per render pass to produce 50 identical booleans. Parses are now memoised in a bounded module-level cache (parseMediaQuery hands out copies, so a caller can't poison it). - **One shared environment store on native.** Each call site used to register THREE native subscriptions (Dimensions, Appearance, AccessibilityInfo.reduceMotionChanged) plus an isReduceMotionEnabled() promise — 20 call sites meant 60 live listeners. Worse, every handler did setEnv(prev => ({ ...prev, … })), always a NEW object, so one rotation re-rendered every instance even though the boolean each derives almost never flips. There is now one store with three listeners total, read through useSyncExternalStore whose snapshot is the resolved **boolean**, so React bails per component unless that component's answer actually changed. - **useBreakpoint renders on band crossings, not resize pixels.** It was backed by useViewportSize, whose untreated resize listener allocates a fresh { width, height } ~60×/s during a window drag, forcing a re-render at every call site before the band was even derived — though resolveBreakpoint yields only 8 values across 7 thresholds. mediaquery now owns a viewport store that wakes subscribers only when the band changes, and the hook's snapshot is the BreakpointKey string. (@knitui/hooks' useViewportSize is untouched — it has other consumers that want the pixel width.) - **useSystemColorScheme** builds its MediaQueryList once at module scope instead of constructing a live, document-registered query object inside getSnapshot (which React calls per render and per store-change check, twice over in StrictMode) and again in subscribe. No public API change in @knitui/mediaquery; useBreakpoint keeps its SSR contract (the server snapshot and first hydrating render both resolve the MediaQueryProvider` seed).
  • minor Memoize .styleable() output so a parent render stops re-rendering the whole kit Tamagui memoizes the component styled() returns unconditionally (createComponent.tsx: res = React.memo(res)). It does not memoize the component .styleable() returns — that is gated behind a flag: ``js // @tamagui/web/src/createComponent.tsx:1906 if (extendedConfig.memo || process.env.TAMAGUI_MEMOIZE_STYLEABLE) { out = React.memo(out); } ` Every public component in this kit is styled(...).styleable(...), and the .styleable() HOC **is** the exported component — 155 sites across @knitui/components (139) and @knitui/dates (16). The kit passed neither memo nor the env var, so the kit's own layer was the only unmemoized layer in the stack: any parent state change re-rendered every kit component in the subtree even when not one of its props had changed. Measured in jsdom on a 300-instance tree (100 rows × Button + Badge + Text), one parent setState with **identical** child props, median of 21 runs: | | median update | | ------ | ------------- | | before | 86.9 ms | | after | 3.8 ms | ≈23× on that shape. With props that genuinely change every render (an inline onPress per row) the win narrows to the low tens of percent — memo then pays for a comparison it cannot skip on. It is not meaningfully negative in either case, because the inner frame is _already_ memoized, so prop-equality skipping is already how this stack behaves. **@knitui/core now exports its own styled** (src/styled.ts), which is Tamagui's factory plus staticConfig.memo = true on the frame it returns. styleable() reads the flag off extendStyledConfig(), which spreads the frame's own staticConfig first and the per-call options.staticConfig second — so setting it on the frame reaches every .styleable() call from one place, including components added later, and a call site can still opt out explicitly with .styleable(render, { staticConfig: { memo: false } }) because its spread wins. styled() does not forward a memo key into staticConfig (verified), so this is a post-hoc assignment rather than an option passed through, and src/essentials.ts no longer re-exports the raw Tamagui styled — there is one styled on the @knitui/core surface. memo is read in exactly **one** place in all of @tamagui/web — the branch quoted above — so setting it has precisely that one effect and no other behavioural change. The env var in the same condition is not a shipping option: it is read at module scope in the consumer's bundle, and neither Metro nor webpack reliably defines it. Verified: the full suite is green (27/27 packages; @knitui/components 120 suites / 1606 tests, @knitui/dates` 49 / 853), the Next production build succeeds, and the extracted CSS is byte-identical to before (69,935 B) — so the Tamagui compiler treats the wrapped factory exactly as it did the raw one.
  • minor Retune the line-height ladder so leading tracks font size consistently lineHeightRatios climbed with font size (1.35 → 1.65), so the largest text got the loosest leading and the smallest got the tightest — the opposite of what typography needs. $xxl headings rendered a 46px line box at 28px, reading as double-spaced, while several steps landed on odd pixel values (23px, 31px). The ladder is now a hump — 1.35 at the caption end, peaking at 1.5 for body, tapering to 1.3 for display — which matches the shape Mantine (body lineHeights plus headings.sizes) and Tailwind both use. Derived line heights per font step: | token | font | before | after | | ----- | ---- | ------ | ----- | | xxs | 12 | 16 | 16 | | xs | 14 | 20 | 20 | | sm | 16 | 23 | 24 | | md | 18 | 27 | 26 | | lg | 20 | 31 | 28 | | xl | 24 | 38 | 32 | | xxl | 28 | 46 | 36 | Every value is now even, so a (height − lineHeight) / 2 centering gap stays on whole pixels, and each one matches the leading Tailwind or Mantine gives that same font size. Also fixed: getLineHeight() multiplied a raw numeric size by the md ratio regardless of the number, so fontSize={28} and fontSize="$xxl" (also 28) produced different line heights. A number now takes the ratio of the nearest step on the font scale, so both resolve to 36. TableOfContents hardcoded its own value * 1.4 for numeric sizes and now goes through the same ladder. This shifts rendered text metrics across the kit — anything measuring or pinning line boxes (notably Textarea row heights, which derive from rows × lineHeight) will lay out slightly differently. Consumers on ^0.5.x opt in explicitly rather than picking it up as a patch.
  • Stop the shared per-render path from doing work no component asked for These paths run for every one of the ~103 components on every render, so the waste multiplied. None of it is behavioural — the rendered output is unchanged. `useGradient` read the theme for a feature that was off. useTheme() was called before the if (!gradient) return bail, so Button, ActionIcon, Badge, Avatar, CloseButton, ThemeIcon, Pill and Alert each paid a full useThemeWithStateuseId + useRef + useReducer + a dependency-less useEffect that fires after every render + snapshot bookkeeping — unless the caller actually passed variant="gradient". On web the theme was never needed at all: a $token resolves to its CSS custom property by pure string transform, so there is now a theme-free theme-color-web.ts and the web path reads no context. On native the theme read moved into <GradientLayer>, which only mounts when a gradient exists. The bail path returns a frozen constant instead of two fresh objects. `ControlIconProvider` made every icon-bearing control a theme subscriber. It resolved its icon colour through useTheme() once per control _section_, so a Button with both a left and a right section instantiated two on top of its own frame. On native that read trips Tamagui's track(), which registers the instance in the global theme-listener map — 100 icon sites meant 100 listener entries and 100 dependency-less effect fires per render pass. Web now resolves the token to var(--…) with no hook; native keeps the theme read, behind a new use-icon-color split. `renderTextChild` cloned every child to answer a question it already knew. React.Children.toArray flattens _and clones_ each element child, and the common branch then discarded the whole cloned array — for containers with element children (Center, Modal.Body, Card) that was pure waste on every render, and for a single string child it allocated an array to learn what typeof children already told it. Now: string/number, single-element and nullish children take fast paths before toArray, and the array scan is one loop instead of some + every. 28 call sites. `slotStyles` allocated for the case where there is nothing to do. Called at ~105 sites, several of them per item (Tree's memoized TreeNode, TreeSelect, and 6× each in TagsInput/MultiSelect/ColorPicker), it built an object plus two closures on every call even when no styles prop was passed. The empty case now returns one shared frozen accessor, and merge only builds a new object when both sides are present — which also means the props spread onto a part keep a stable identity, so downstream React.memo and Tamagui prop comparisons can bail out. (Verified safe: no call site mutates an accessor result.) The dev-only known-slot Set is cached in a WeakMap keyed by the *_SLOT_KEYS constant instead of being rebuilt per render. `Button`'s slot collection re-walked its children every render. An inline options literal, a visit closure, a per-child linear scan of the slot registry and a rest-spread per marker child — and because the pooled array became the label's children, the label's children identity changed on every render even for <Button>Save</Button>. Marker matching is now a Map built once in defineSlots, the options object is a module constant, and a non-array child with no marker skips collection entirely. `useSlotTextWrapper` was remounting the text it exists to keep mounted. The memo keyed on slotProps _identity_, but the documented usage is an inline styles={{ label: { … } }} — a fresh object every render. So the wrapper was a new element _type_ each render and React unmounted and remounted the wrapped text, which is precisely the failure the hook was written to prevent, affecting only the callers who used the feature. Slot props are now compared by shallow value. Objects that never varied are now module constants. webButton(), webButtonTextReset() and Button's native id props were rebuilt per render even though isWeb is fixed at build time; UnstyledButton also built a fresh style _array_ every render, which defeated Tamagui's style caching. Group's grow style and the Tooltip/HoverCard trigger-clone handler bundles are memoized, so cloning a child no longer hands it a new style object and break its React.memo — which is what was re-rendering memoized icons inside a <Group grow>. `Paper` and `Typography` dropped a component layer each. Both were .styleable wrappers that added zero or one prop, so they are now plain styled() exports.

@knitui/components

  • minor Cut the shipped theme suite from 440 themes to 22, and stop the mediaquery hooks re-parsing / re-rendering per frame ## @knitui/core — 418 generated themes removed config/themes.ts called createThemes(...) without a componentThemes argument, so it inherited Tamagui's defaultComponentThemes — a 19-entry map that is itself @deprecated upstream ("component themes are no longer recommended"). Those entries cross-multiply with every scheme × palette, so the kit shipped 2 × 11 × 20 = 440 themes, all built eagerly at module scope on every app start and again by the Tamagui babel/static compiler. Measured against the kit's real inputs (interpreter-only, node --jitless): | | themes | theme keys | resolved Variables | heap | build | | ------ | ------ | ---------- | --------------------- | -------- | ------- | | before | 440 | 37,708 | 37,708 (2,822 unique) | 2,513 KB | 12.1 ms | | after | 22 | 1,870 | 1,870 (1,010 unique) | 208 KB | 2.8 ms | componentThemes: false is set both in the stock config/themes.ts and in createTheme(), so a consumer-built config has the same theme set as the shipped one (24 names there — the stock 22 plus the brand alias in both schemes). ### BREAKING for anyone naming a component theme Themes named <scheme>[_<palette>]_<Component> no longer exist. If you reference one by name — <Theme name="light_Button">, a themes override keyed dark_gray_Card, or a createTheme({ themeBuilder: { componentThemes: … } }) extension — it will no longer resolve and the nearest parent theme is used instead. The removed names are the 20 Tamagui defaults (Button, Card, Checkbox, Input, ListItem, Progress, ProgressIndicator, RadioGroupItem, SelectItem, SelectTrigger, SliderThumb, SliderTrack, SliderTrackActive, Switch, SwitchThumb, TextArea, Tooltip, TooltipArrow, TooltipContent) × 22 scheme/palette combinations. To get them back, pass your own map: ``ts createTheme({ themeBuilder: { componentThemes: { Card: { template: "surface1" } } } }); ` ### @knitui/components — offsets that were implicit are now explicit Nine of the kit's styled() names collided with that default map and were silently receiving a template offset. Every one of them has been PINNED so the rendered colour is unchanged — verified value-by-value across light, dark, light_blue and dark_red (100 probes, 0 differences): | component | template | change | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | ListItem | surface1 | none — the frame paints nothing, and surface* leaves $color1…$color12 and $color alone | | Progress | surface1 | none — already explicit $colorN | | Button | surface3 | variant="default" border pinned $borderColor$color7 (the only variant reading $borderColor) | | Card | surface1 | frame + Card.Section border pinned to $color2 / $color5 | | Checkbox | surface2 | unchecked box pinned to $color3 / $color6 | | SliderTrack | inverse | ramp steps written pre-mirrored ($color3$color10; child SliderBar $color9$color4) | | SliderThumb | inverse | pre-mirrored ($color1$color12, $color9$color4, hover $color10$color3; child SliderLabelBubble too) | | SwitchThumb | inverse | child SwitchThumbIndicator pre-mirrored ($color5$color8, on $color9$color4). The thumb itself uses $white, a token, so it was never inverted | | Tooltip | inverse | frame $color9$color4, Tooltip.Text $color1$color12 | Two things worth flagging to whoever owns the visual design: 1. The four inverse collisions were **reversing the entire $color1…$color12 ramp** under those subtrees, not offsetting one step. So Tooltip was authored $color9 fill + $color1 label (the kit's canonical filled pairing) and actually painted step 4 + step 12; the slider's track/bar/thumb were likewise inverted. The pins preserve the shipped pixels, but they codify an appearance that came from a deprecated Tamagui default rather than a design decision. Un-mirroring them (back to $color9/$color1 etc.) is a deliberate follow-up. 2. **Descendants** of those nine frames no longer inherit the offset theme. A component placed inside a Card used to resolve $background one ramp step up ($color2); it now gets the page value ($color1). Only the nine frames' own painted surfaces are pinned — arbitrary user content inside them shifts by one step (or un-inverts, inside the Slider/Tooltip parts). config/OVER_MEDIA also moved to its own module (config/over-media.ts) so importing those five scrim literals no longer drags in the whole theme generation. No export surface change. ## @knitui/mediaquery — no per-render parsing, 3N listeners → 3 - **matchesQuery no longer re-parses on every call.** The native useMediaQuery evaluated its query on the render path, and a string query re-ran the full parser each time: for "(min-width: 768px) and (orientation: landscape)" that is ~6 regex executions plus ~10 allocations, per render, per hook instance — so a 50-row list did ~300 regex ops per render pass to produce 50 identical booleans. Parses are now memoised in a bounded module-level cache (parseMediaQuery hands out copies, so a caller can't poison it). - **One shared environment store on native.** Each call site used to register THREE native subscriptions (Dimensions, Appearance, AccessibilityInfo.reduceMotionChanged) plus an isReduceMotionEnabled() promise — 20 call sites meant 60 live listeners. Worse, every handler did setEnv(prev => ({ ...prev, … })), always a NEW object, so one rotation re-rendered every instance even though the boolean each derives almost never flips. There is now one store with three listeners total, read through useSyncExternalStore whose snapshot is the resolved **boolean**, so React bails per component unless that component's answer actually changed. - **useBreakpoint renders on band crossings, not resize pixels.** It was backed by useViewportSize, whose untreated resize listener allocates a fresh { width, height } ~60×/s during a window drag, forcing a re-render at every call site before the band was even derived — though resolveBreakpoint yields only 8 values across 7 thresholds. mediaquery now owns a viewport store that wakes subscribers only when the band changes, and the hook's snapshot is the BreakpointKey string. (@knitui/hooks' useViewportSize is untouched — it has other consumers that want the pixel width.) - **useSystemColorScheme** builds its MediaQueryList once at module scope instead of constructing a live, document-registered query object inside getSnapshot (which React calls per render and per store-change check, twice over in StrictMode) and again in subscribe. No public API change in @knitui/mediaquery; useBreakpoint keeps its SSR contract (the server snapshot and first hydrating render both resolve the MediaQueryProvider` seed).
  • minor Retune the line-height ladder so leading tracks font size consistently lineHeightRatios climbed with font size (1.35 → 1.65), so the largest text got the loosest leading and the smallest got the tightest — the opposite of what typography needs. $xxl headings rendered a 46px line box at 28px, reading as double-spaced, while several steps landed on odd pixel values (23px, 31px). The ladder is now a hump — 1.35 at the caption end, peaking at 1.5 for body, tapering to 1.3 for display — which matches the shape Mantine (body lineHeights plus headings.sizes) and Tailwind both use. Derived line heights per font step: | token | font | before | after | | ----- | ---- | ------ | ----- | | xxs | 12 | 16 | 16 | | xs | 14 | 20 | 20 | | sm | 16 | 23 | 24 | | md | 18 | 27 | 26 | | lg | 20 | 31 | 28 | | xl | 24 | 38 | 32 | | xxl | 28 | 46 | 36 | Every value is now even, so a (height − lineHeight) / 2 centering gap stays on whole pixels, and each one matches the leading Tailwind or Mantine gives that same font size. Also fixed: getLineHeight() multiplied a raw numeric size by the md ratio regardless of the number, so fontSize={28} and fontSize="$xxl" (also 28) produced different line heights. A number now takes the ratio of the nearest step on the font scale, so both resolve to 36. TableOfContents hardcoded its own value * 1.4 for numeric sizes and now goes through the same ladder. This shifts rendered text metrics across the kit — anything measuring or pinning line boxes (notably Textarea row heights, which derive from rows × lineHeight) will lay out slightly differently. Consumers on ^0.5.x opt in explicitly rather than picking it up as a patch.
  • Stop hot components re-rendering their whole subtree on every keystroke, drag frame and scroll sample A pass over the components whose cost scaled with the wrong thing — the number of options, marks, pills, rows or tree nodes rather than the number that actually changed. All of these are the same shape of defect: a memo that could never hit, or a derivation re-run per node instead of once. The select family (`Select`, `MultiSelect`, `TagsInput`, `TreeSelect`, `Autocomplete`) — each Root listed __triggerProps in its context useMemo dep array, but the sugar wrapper assembles that object fresh on every render from a rest spread (...inputProps), so its identity can never be preserved. The context value was therefore new every render and every consumer re-rendered — including the deliberately memoized option row, because context propagation walks _past_ React.memo. A comment claimed the churn was intentional; it was self-inflicted. __triggerProps now rides its own context, read by the one node that consumes it (Select.Trigger), while the option rows keep subscribing to the behaviour context. Removing it from the deps only helps if the memo can then be trusted, so every Root handler is useCallbackRef-stable (fixed identity, latest closure) and each dep array is now complete with the eslint-disable gone — previously a hitting memo would have served a stale onSearchChange or a stale __triggerProps callback. handleSubmit matters twice over: it lands on <Combobox onOptionSubmit>, a dep of Combobox's own context memo, so churn there reached every Combobox.Option regardless. `MultiSelect` / `TagsInput` pills — the pills were mapped un-memoized, each one subscribing to the context that also carries search, so typing one character re-rendered every chip (a Pill + CloseButton + icon SVG each), and each got a fresh onRemove closure because removeValue/removeTag depended on the current value array. Keystroke latency scaled with the number of selected values. The chips now subscribe to a search-free context slice and are React.memo'd, with a stable remove callback, so a keystroke re-renders the field and skips the pills. `Tree`isNodeChecked and isNodeIndeterminate each ran a full recursive traversal that allocated a status object per node and did checkedState.includes(…) per leaf. The documented pattern calls both for every node, so a render was two full traversals _per node_: for 200 nodes with 50 checked, roughly 80k object allocations and 2M string compares. useTree now builds one Map<value, status> per (data, checkedState) in a single pass (with a Set for membership) and both helpers are O(1) lookups. The exported pure helpers keep their signatures and semantics. `Table`Table.Tbody cloned every row to inject an index and Table.Tr cloned every cell to mark the first one, so a 500×8 composed table allocated ~4,500 extra element objects and re-walked its children twice per render, with no memo on the context-consuming rows and cells. Both walks are replaced by small internal contexts, and each is skipped entirely when the feature that needs it is off: zero child walking for a default table, 500 provider elements (not 4,000 clones) with column dividers on. Rows are no longer cloned, so a memoized row can finally bail out. One incidental fix: first-cell marking used Children.map's index, which counts null/false slots, so a conditionally-rendered leading cell drew a divider on the wrong cell. `VirtualList`slots.get("item") ?? {} allocated a fresh object every render and handed it to the memoized Row, so every mounted row re-rendered on every windowing render and every measurement-driven layout bump; scroll jank scaled with the window size instead of with rows entering and leaving. Now a module-level constant. Fixing the memo exposed that extraData — documented as the re-render trigger for renderItem — was never passed to the row and only "worked" because the memo was broken; it is now a real row prop. Separately, handleScroll changed identity every render (through headerH/footerH state and inline range/end-reached callbacks) and lands on ScrollArea's onScrollPositionChange, which is a dep of the native useAnimatedScrollHandler — so the Reanimated worklet scroll handler was rebuilt on every windowing render, i.e. mid-fling. The scroll callbacks are now useCallbackRef-stable with the chrome heights mirrored into refs (written synchronously alongside the state, so windowing can't read a stale header height between a chrome commit and the effect flush). `ColorPicker` — the saturation area and both sliders mirrored their position into state via an effect keyed on values that change every drag frame, always with a new object, so each pointermove cost two renders of the whole picker subtree. position is now derived during render from the parsed colour that was already the source of truth. The []-deps handleChange workaround is gone with it: the stale-setter hazard it papered over is fixed in useUncontrolled, and an honest dep list keeps its identity stable — which matters, because it is a dep of the ColorPickerContext memo. `Slider` / `RangeSlider` / `AngleSlider` — marks were re-mapped inline in the render body, 2–3 styled components each with no memo, so a 20-mark slider rebuilt ~45 Tamagui frames per pointermove. Marks moved into a memoized layer: Slider memoizes per mark with the filled span passed as two numbers (so no unstable function prop breaks equality) and only the 0–1 marks whose filled state actually flips re-render; AngleSlider has no value-dependent mark styling, so its whole mark subtree is skipped. A parent re-render that doesn't touch the value (hover, focus, label bubble) now skips the marks entirely. The per-move onChange contract is unchanged. Smaller onesCombobox.OptionsDropdown built a Set for the selected values instead of value.includes(…) per option (500 options × 50 selected: 25k string compares per recompute → 500 hash lookups); the single-select path still uses a scalar compare and allocates nothing. Accordion memoized its per-item context value, which was an inline object literal and so re-rendered every Accordion.Control/Panel on any item render. SegmentedControl compares the measured layout before setting state, so an onLayout re-fire from a parent reflow or a font change no longer re-renders the root and every segment with an identical value. No public API changes. Table's internal __index/__first props are gone (no test or consumer asserted on them) and Tree gained an exported getCheckedNodesMap helper.
  • Stop the icon barrel from being pulled into every component @knitui/components dragged all ~6.1k icon modules into any app that imported a single component. The kit SRC-SHIPS, so Metro compiles what it resolves and does NOT tree-shake: one line in internal/ControlIconProvider.tsximport { IconProvider } from "@knitui/icons" — resolved the root barrel (packages/icons/src/index.ts, 378 KB / 6,153 export lines), and because that module is reachable from Button, Chip, Accordion and friends, every glyph became part of the graph. Walking the import graph from each package entry, honouring exports conditions and .web/.native order: | entry | before | after | | -------------------- | ------------------------ | ---------------------- | | @knitui/components | 6,490 modules / 4,896 KB | 344 modules / 1,635 KB | | @knitui/sheet | 6,506 modules / 4,960 KB | 360 modules / 1,700 KB | | @knitui/carousel | 6,541 modules / 5,068 KB | 398 modules / 1,808 KB | | @knitui/media | 6,530 modules / 5,056 KB | 384 modules / 1,796 KB | That is −94.7% modules and −3,261 KB of source off the components graph, and it lands on Metro cold start, on every clear-cache rebuild, and on the JS bundle itself for consumers whose bundler cannot shake a src-shipped barrel. `@knitui/icons` gains two subpath exports (the minor): - @knitui/icons/contextIconProvider, useIconContext and their types - @knitui/icons/typesIconProps, IconNode, IconComponent, IconType Per-glyph deep imports (@knitui/icons/IconCheck) already resolved through the existing ./Icon* wildcard; the provider was the one thing that had no subpath and therefore forced the barrel. Nothing was removed — the root barrel still exports everything it did. Internal import rewrites (patch, no API change) across the shipped source of @knitui/components (ControlIconProvider, Accordion, Checkbox/CheckIcon, Chip, CloseButton, Combobox, Stepper), @knitui/carousel (Carousel) and @knitui/media (the audio/video chrome + LiveAudioMeter), each now naming the glyph module it actually uses. An ESLint no-restricted-imports guardrail in eslint.config.base.mjs blocks the bare @knitui/icons / @knitui/emoji specifier in shipped src/** so this cannot regress; stories, tests and the private @knitui/demo galleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: - sideEffects was missing from @knitui/media, @knitui/sheet and @knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it. media and sheet are declared sideEffects: false (audited: their only module-scope statements are pure const initialisers and a displayName assignment — the one registerProcessor() call in media lives inside an AudioWorklet source string, not in the module). plugins lists just its babel-plugin entry, which really does mutate process.env.TAMAGUI_IGNORE_BUNDLE_ERRORS on load. - @knitui/plugins/next-plugin now sets experimental.optimizePackageImports for @knitui/icons, @knitui/emoji, @knitui/components, @knitui/dates and @knitui/map, so every Next consumer inherits it instead of having to know. Next has to build a barrel's full module graph before it can shake it, and transpilePackages means it compiles the kit's source to do so — with this, a bare-barrel glyph import in app code is rewritten to that glyph's own module and the other 6,145 files are never compiled. Any experimental the app already set is merged, not replaced, and its own optimizePackageImports entries are kept.
  • Stop the shared per-render path from doing work no component asked for These paths run for every one of the ~103 components on every render, so the waste multiplied. None of it is behavioural — the rendered output is unchanged. `useGradient` read the theme for a feature that was off. useTheme() was called before the if (!gradient) return bail, so Button, ActionIcon, Badge, Avatar, CloseButton, ThemeIcon, Pill and Alert each paid a full useThemeWithStateuseId + useRef + useReducer + a dependency-less useEffect that fires after every render + snapshot bookkeeping — unless the caller actually passed variant="gradient". On web the theme was never needed at all: a $token resolves to its CSS custom property by pure string transform, so there is now a theme-free theme-color-web.ts and the web path reads no context. On native the theme read moved into <GradientLayer>, which only mounts when a gradient exists. The bail path returns a frozen constant instead of two fresh objects. `ControlIconProvider` made every icon-bearing control a theme subscriber. It resolved its icon colour through useTheme() once per control _section_, so a Button with both a left and a right section instantiated two on top of its own frame. On native that read trips Tamagui's track(), which registers the instance in the global theme-listener map — 100 icon sites meant 100 listener entries and 100 dependency-less effect fires per render pass. Web now resolves the token to var(--…) with no hook; native keeps the theme read, behind a new use-icon-color split. `renderTextChild` cloned every child to answer a question it already knew. React.Children.toArray flattens _and clones_ each element child, and the common branch then discarded the whole cloned array — for containers with element children (Center, Modal.Body, Card) that was pure waste on every render, and for a single string child it allocated an array to learn what typeof children already told it. Now: string/number, single-element and nullish children take fast paths before toArray, and the array scan is one loop instead of some + every. 28 call sites. `slotStyles` allocated for the case where there is nothing to do. Called at ~105 sites, several of them per item (Tree's memoized TreeNode, TreeSelect, and 6× each in TagsInput/MultiSelect/ColorPicker), it built an object plus two closures on every call even when no styles prop was passed. The empty case now returns one shared frozen accessor, and merge only builds a new object when both sides are present — which also means the props spread onto a part keep a stable identity, so downstream React.memo and Tamagui prop comparisons can bail out. (Verified safe: no call site mutates an accessor result.) The dev-only known-slot Set is cached in a WeakMap keyed by the *_SLOT_KEYS constant instead of being rebuilt per render. `Button`'s slot collection re-walked its children every render. An inline options literal, a visit closure, a per-child linear scan of the slot registry and a rest-spread per marker child — and because the pooled array became the label's children, the label's children identity changed on every render even for <Button>Save</Button>. Marker matching is now a Map built once in defineSlots, the options object is a module constant, and a non-array child with no marker skips collection entirely. `useSlotTextWrapper` was remounting the text it exists to keep mounted. The memo keyed on slotProps _identity_, but the documented usage is an inline styles={{ label: { … } }} — a fresh object every render. So the wrapper was a new element _type_ each render and React unmounted and remounted the wrapped text, which is precisely the failure the hook was written to prevent, affecting only the callers who used the feature. Slot props are now compared by shallow value. Objects that never varied are now module constants. webButton(), webButtonTextReset() and Button's native id props were rebuilt per render even though isWeb is fixed at build time; UnstyledButton also built a fresh style _array_ every render, which defeated Tamagui's style caching. Group's grow style and the Tooltip/HoverCard trigger-clone handler bundles are memoized, so cloning a child no longer hands it a new style object and break its React.memo — which is what was re-rendering memoized icons inside a <Group grow>. `Paper` and `Typography` dropped a component layer each. Both were .styleable wrappers that added zero or one prop, so they are now plain styled() exports.
  • Stop overlays, scrolling and looping animations from doing per-frame work A pass over every hot path that runs while something is moving — a scroll, a drag, an open/close animation — removing work that ran once per frame and did not need to. Floating overlays (web)autoUpdate's scroll/resize/element-resize triggers are now coalesced into one requestAnimationFrame. The capture-phase scroll listener fires for every scrollable ancestor in the app, and each trigger ran a full getBoundingClientRectcomputePositionsetState cycle: layout reads interleaved with the previous pass's style writes, i.e. a forced synchronous reflow per event, several per frame. The first position is still computed synchronously, so nothing flashes at an un-positioned origin. The repositioning bail-out also stopped JSON.stringify-ing both middleware-data bags (twice per pass, per open overlay) in favour of a shallow compare. `ScrollArea` — the scrollbar auto-hide no longer crosses the JS/UI boundary every frame on native: type="hover"/"scroll" (the default) needs only a boolean plus a trailing timer, so its runOnJS hop is rate-limited on the UI thread, while a real consumer (onScrollPositionChange, onScrollEnd, the reach callbacks, stickToBottom, shadows) still gets every sample. The thumb's per-frame animated style carries a transform ONLY — the hover-grow inset moved into its own style — so a scroll no longer pushes layout props (left/right/height) through Yoga on every frame. Edge fades (shadows) keep four booleans in state instead of mirroring the raw offset, so a fling re-renders at most once per edge crossing rather than once per frame. The web ResizeObserver is created once and its observations reconciled per commit, instead of being torn down and rebuilt over every child whenever the child count changed — which, for a windowed list inside a ScrollArea, happened mid-fling on every window advance. Looping animationsSkeleton, Loader, Progress and Indicator declare their loops unconditionally for stable hook order but render only some of them; every undisplayed loop was still scheduled, as a live compositor animation on web and a withRepeat re-evaluating a worklet every frame on native. A Loader paid for four, a static Indicator and a non-animate Skeleton for one each. Loops are now scheduled only when they actually animate. `useReducedMotion` — one shared subscription per app instead of one matchMedia / AccessibilityInfo listener per component (nearly every animated part in the kit calls it), and the correct value on first render, removing a mount-time re-render per instance. `useElementSize` — bails when a re-measure reports the size it already had, so a ResizeObserver/onLayout re-fire from a visibility flip or a sibling's reflow no longer re-renders Collapse/Spoiler/Marquee content. `Sheet` — the close watcher's gate is evaluated on the UI thread, so a dragging or springing panel stops waking the JS thread on every frame with an offset the subscriber immediately discards. `Sheet`'s pan gesture was rebuilt on every render. animationConfig is a flat record that every call site writes inline, and it is a dependency of useSheetDrag's useMemo — so a fresh identity rebuilt the Gesture.Pan() object each render, and RNGH responds by tearing down and re-attaching the native gesture handler. Mid-drag, that is a dropped gesture. It is now collapsed to a stable reference while its values are unchanged, via a new internal useStableRecord (with tests) — the sibling of carousel's modeConfig stabilisation and map's useStableStyleValue. withAnimation/gestureConfig are deliberately left alone: they are functions consumed inside worklets, where a JS-thread ref wrapper would break worklet semantics. No public API changes; useLoopingAnimation (internal) gained an enabled option.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/icons@0.6.0 - @knitui/core@0.6.0 - @knitui/hooks@0.6.0

@knitui/hooks

  • Stop overlays, scrolling and looping animations from doing per-frame work A pass over every hot path that runs while something is moving — a scroll, a drag, an open/close animation — removing work that ran once per frame and did not need to. Floating overlays (web)autoUpdate's scroll/resize/element-resize triggers are now coalesced into one requestAnimationFrame. The capture-phase scroll listener fires for every scrollable ancestor in the app, and each trigger ran a full getBoundingClientRectcomputePositionsetState cycle: layout reads interleaved with the previous pass's style writes, i.e. a forced synchronous reflow per event, several per frame. The first position is still computed synchronously, so nothing flashes at an un-positioned origin. The repositioning bail-out also stopped JSON.stringify-ing both middleware-data bags (twice per pass, per open overlay) in favour of a shallow compare. `ScrollArea` — the scrollbar auto-hide no longer crosses the JS/UI boundary every frame on native: type="hover"/"scroll" (the default) needs only a boolean plus a trailing timer, so its runOnJS hop is rate-limited on the UI thread, while a real consumer (onScrollPositionChange, onScrollEnd, the reach callbacks, stickToBottom, shadows) still gets every sample. The thumb's per-frame animated style carries a transform ONLY — the hover-grow inset moved into its own style — so a scroll no longer pushes layout props (left/right/height) through Yoga on every frame. Edge fades (shadows) keep four booleans in state instead of mirroring the raw offset, so a fling re-renders at most once per edge crossing rather than once per frame. The web ResizeObserver is created once and its observations reconciled per commit, instead of being torn down and rebuilt over every child whenever the child count changed — which, for a windowed list inside a ScrollArea, happened mid-fling on every window advance. Looping animationsSkeleton, Loader, Progress and Indicator declare their loops unconditionally for stable hook order but render only some of them; every undisplayed loop was still scheduled, as a live compositor animation on web and a withRepeat re-evaluating a worklet every frame on native. A Loader paid for four, a static Indicator and a non-animate Skeleton for one each. Loops are now scheduled only when they actually animate. `useReducedMotion` — one shared subscription per app instead of one matchMedia / AccessibilityInfo listener per component (nearly every animated part in the kit calls it), and the correct value on first render, removing a mount-time re-render per instance. `useElementSize` — bails when a re-measure reports the size it already had, so a ResizeObserver/onLayout re-fire from a visibility flip or a sibling's reflow no longer re-renders Collapse/Spoiler/Marquee content. `Sheet` — the close watcher's gate is evaluated on the UI thread, so a dragging or springing panel stops waking the JS thread on every frame with an offset the subscriber immediately discards. `Sheet`'s pan gesture was rebuilt on every render. animationConfig is a flat record that every call site writes inline, and it is a dependency of useSheetDrag's useMemo — so a fresh identity rebuilt the Gesture.Pan() object each render, and RNGH responds by tearing down and re-attaching the native gesture handler. Mid-drag, that is a dropped gesture. It is now collapsed to a stable reference while its values are unchanged, via a new internal useStableRecord (with tests) — the sibling of carousel's modeConfig stabilisation and map's useStableStyleValue. withAnimation/gestureConfig are deliberately left alone: they are functions consumed inside worklets, where a JS-thread ref wrapper would break worklet semantics. No public API changes; useLoopingAnimation (internal) gained an enabled option.
  • Give useUncontrolled a referentially stable setter handleUncontrolledChange was a plain function declaration, so the setter had a new identity on every render — and the controlled branch handed back the caller's raw onChange, which callers almost always pass as an inline arrow. Either way, every consumer got a fresh setter every render. Around 48 files across components, dates and sheet call this hook and pass that setter straight into a child, a context value, or a useMemo/useEffect dependency list, so the churn cascaded. The clearest case was Combobox: a new setOpen made use-combobox's useMemo store a new object, which made the ComboboxContext value a new object — and because context propagation walks _past_ React.memo, the deliberately memoized option row re-rendered anyway. Typing one character re-resolved every option frame in an open dropdown, with the cost scaling in the option count. NumberInput's handler-assignment effect re-ran on every render for the same reason. Both branches now return one stable setter, with the latest onChange read through a ref (useCallbackRef) rather than closed over. That second half fixes a class of stale-closure bug as well: a consumer that memoized a handler with [] deps around the setter previously kept calling the onChange from its first render — ColorPicker's handleChange did exactly that — and now always invokes the current one. Behaviour is otherwise unchanged: the controlled branch still never tracks its own state, defaultValue still wins over finalValue, and a missing onChange is still a no-op. Added a test suite covering the value semantics, the setter's identity across re-renders and state changes in both branches, and the always-latest-onChange guarantee.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/core@0.6.0

@knitui/icons

  • minor Stop the icon barrel from being pulled into every component @knitui/components dragged all ~6.1k icon modules into any app that imported a single component. The kit SRC-SHIPS, so Metro compiles what it resolves and does NOT tree-shake: one line in internal/ControlIconProvider.tsximport { IconProvider } from "@knitui/icons" — resolved the root barrel (packages/icons/src/index.ts, 378 KB / 6,153 export lines), and because that module is reachable from Button, Chip, Accordion and friends, every glyph became part of the graph. Walking the import graph from each package entry, honouring exports conditions and .web/.native order: | entry | before | after | | -------------------- | ------------------------ | ---------------------- | | @knitui/components | 6,490 modules / 4,896 KB | 344 modules / 1,635 KB | | @knitui/sheet | 6,506 modules / 4,960 KB | 360 modules / 1,700 KB | | @knitui/carousel | 6,541 modules / 5,068 KB | 398 modules / 1,808 KB | | @knitui/media | 6,530 modules / 5,056 KB | 384 modules / 1,796 KB | That is −94.7% modules and −3,261 KB of source off the components graph, and it lands on Metro cold start, on every clear-cache rebuild, and on the JS bundle itself for consumers whose bundler cannot shake a src-shipped barrel. `@knitui/icons` gains two subpath exports (the minor): - @knitui/icons/contextIconProvider, useIconContext and their types - @knitui/icons/typesIconProps, IconNode, IconComponent, IconType Per-glyph deep imports (@knitui/icons/IconCheck) already resolved through the existing ./Icon* wildcard; the provider was the one thing that had no subpath and therefore forced the barrel. Nothing was removed — the root barrel still exports everything it did. Internal import rewrites (patch, no API change) across the shipped source of @knitui/components (ControlIconProvider, Accordion, Checkbox/CheckIcon, Chip, CloseButton, Combobox, Stepper), @knitui/carousel (Carousel) and @knitui/media (the audio/video chrome + LiveAudioMeter), each now naming the glyph module it actually uses. An ESLint no-restricted-imports guardrail in eslint.config.base.mjs blocks the bare @knitui/icons / @knitui/emoji specifier in shipped src/** so this cannot regress; stories, tests and the private @knitui/demo galleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: - sideEffects was missing from @knitui/media, @knitui/sheet and @knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it. media and sheet are declared sideEffects: false (audited: their only module-scope statements are pure const initialisers and a displayName assignment — the one registerProcessor() call in media lives inside an AudioWorklet source string, not in the module). plugins lists just its babel-plugin entry, which really does mutate process.env.TAMAGUI_IGNORE_BUNDLE_ERRORS on load. - @knitui/plugins/next-plugin now sets experimental.optimizePackageImports for @knitui/icons, @knitui/emoji, @knitui/components, @knitui/dates and @knitui/map, so every Next consumer inherits it instead of having to know. Next has to build a barrel's full module graph before it can shake it, and transpilePackages means it compiles the kit's source to do so — with this, a bare-barrel glyph import in app code is rewritten to that glyph's own module and the other 6,145 files are never compiled. Any experimental the app already set is merged, not replaced, and its own optimizePackageImports entries are kept.
  • Stop publishing the lib/ build output that nothing can resolve Both packages listed "lib" in files and ran bob build, but every resolver key — main, module, types, react-native, source and every exports condition — points at ./src/.... Nothing in either package, the workspace, or a consumer's resolution can reach lib/: these two src-ship, like the rest of the kit. The build output was shipped to npm as dead weight. Measured with npm pack --dry-run: | package | before | after | | --------------- | ---------------------- | --------------------- | | @knitui/icons | 23.6 MB / 43,096 files | 3.6 MB / 6,159 files | | @knitui/emoji | — | 26.9 MB / 7,614 files | lib/ was 36,937 of the 43,096 icon entries — 85% of that tarball — and 180 MB on disk for emoji. Install size only; no bundle, API or resolution change, which is exactly why it was invisible. bob build still runs, so the artefact is still produced locally and in CI. If that turns out to have no consumer either, the build target itself can go — but that is a separate call from what gets published.

@knitui/emoji

  • Stop publishing the lib/ build output that nothing can resolve Both packages listed "lib" in files and ran bob build, but every resolver key — main, module, types, react-native, source and every exports condition — points at ./src/.... Nothing in either package, the workspace, or a consumer's resolution can reach lib/: these two src-ship, like the rest of the kit. The build output was shipped to npm as dead weight. Measured with npm pack --dry-run: | package | before | after | | --------------- | ---------------------- | --------------------- | | @knitui/icons | 23.6 MB / 43,096 files | 3.6 MB / 6,159 files | | @knitui/emoji | — | 26.9 MB / 7,614 files | lib/ was 36,937 of the 43,096 icon entries — 85% of that tarball — and 180 MB on disk for emoji. Install size only; no bundle, API or resolution change, which is exactly why it was invisible. bob build still runs, so the artefact is still produced locally and in CI. If that turns out to have no consumer either, the build target itself can go — but that is a separate call from what gets published.

@knitui/dates

  • Stop the calendar grids re-deriving every cell on every render The package had no useMemo, useCallback or React.memo anywhere outside use-dates-context, so every render re-derived the entire month grid from scratch — and a web hover move is a render, because it updates hoveredDate on the picker root. Counted with a dayjs-prototype shim, one default <DatePicker type="range"> (5-week month, 35 cells) cost 2,747 dayjs instances and 194 `format()` calls to mount, and 2,442 / 193 to cross a single cell with the pointer. Each instance is a new Date plus an object; format with MMMM hits the locale table and runs a regex replace. Dragging across a row fires seven of those in ~150 ms. Per-render cost after this change, same measurements: | scenario | dayjs before | after | format before | after | | ---------------------------------------- | -----------: | ------: | --------------: | ------: | | Month re-render (35 cells) | 724 | 81 | 187 | 8 | | DatePicker type="range" mount | 2,747 | 233 | 194 | 49 | | DatePicker type="range" one hover move | 2,442 | 68 | 193 | 13 | | …with numberOfColumns={2} | 6,442 | 646 | 523 | 134 | Redundant per-cell prop getters. getDateInTabOrder invoked getDayProps(date) twice for all 42 dates (once for disabled, once for selected) and Month's cell loop invoked it a third time. DatePicker wires that getter to useDatesState's getControlProps, which for a range picker runs rangeView.some(isSame) + isDateInRange + isFirstInRange + isLastInRange — ~20 dayjs instances a call. So ~1,400 instances went on deciding which single cell gets tabIndex={0}. The getter is now resolved once per date into a per-render Map shared by both consumers. Same fix in MonthsList/YearsList via getMonthInTabOrder/getYearInTabOrder. String comparisons instead of dayjs round-trips. isSameMonth, isBeforeMaxDate, isAfterMinDate, isInRange and useDatesState's isSame(date, level) all took values that are ALREADY zero-padded YYYY-MM-DD — the package's canonical shape — and parsed them back into dayjs to compare. Fixed-width big-endian date strings sort chronologically, so a prefix compare is exact and allocation-free: isInRange went from ~11 instances plus a [...range].sort() per call (up to 3 calls per cell) to zero. Every one keeps its dayjs path as a fallback for Date objects and non-canonical strings, and each was verified against the original implementation over a dense input space (leap-year and month/year boundaries, both DST transitions; ~91k combinations for isInRange). Memoized grid derivation. getMonthDays (~70 instances + 35 format calls) is memoized behind a small module-level LRU, so navigating back to a visited month is free. The selection-INDEPENDENT per-cell facts — outside/hidden/weekend/disabled and each cell's aria-label — move into one useMemo; only getDayProps and the roving tabIndex, which read live state, stay in the loop. Same treatment for the 12-month and 10-year levels, whose labels are fixed for the whole year/decade but were re-formatted per cell per render. Memoized cells. Cell rendering moved into an internal memoized component (the pattern Combobox's OptionRow and TagsInput's chips already use) with a comparator that looks one level into plain-object props — necessary because getDayProps(date) and the styles slot accessors return fresh objects holding unchanged booleans. The grid's __-prefixed callbacks are stabilised with useCallbackRef so the comparator can bail. A hover move now re-renders 1 cell instead of 35, which also stops 42 refs detaching and reattaching, re-running __getDayRef and rebuilding the level group's daysRefs matrix, on every render. `Day` no longer computes a label that is thrown away. Month always passes its own aria-label through ...rest, spread after Day's default — so the day.locale(…) fallback is now built only when there is no explicit label (which also means native accessibilityLabel announces the custom label instead of the generic date). The today check skips its work unless highlightToday is set, and reads three calendar fields off the existing instance rather than allocating a Date plus three dayjs clones per cell. **TimePicker dropdown.** A 24h-with-seconds dropdown renders 24 + 60 + 60 controls, and every keystroke in the segment inputs updates controller.values, rebuilding all 144 plus their range arrays. The ranges are memoized, TimeControl is React.memo'd, and onSelect is stabilised so the compare can hold: an unrelated re-render now re-renders **0** controls (was all 144) and changing a column's value re-renders **2** — the one losing active and the one gaining it. No public API changes — Day's and Month's prop contracts are untouched, and the memoized cell is internal. getMonthDays, isSameMonth, isInRange and friends keep their exact signatures and return values. **MonthsList and YearsList got the memoized cell too.** Month had a memoized day cell; the other two grids did not, so a RANGE month/year picker — which updates hoveredDate on every web hover move — re-rendered all 12/10 PickerControl leaves and detached/reattached every ref (re-running __getControlRef and rebuilding the refs matrix) for a change that moved two cells' background colour. Both now use the same pattern, with the four __-prefixed grid callbacks stabilised via useCallbackRef so the compare can actually hold. The one-level-deep memo comparator is now shared by all three grids in internal/are-cell-props-equal (with tests) rather than living privately in Month. It stays deliberately shallow: anything it cannot prove equal — a nested object, a fresh function — reads as changed and simply re-renders the cell. **WeekdaysRow rebuilt its 7 labels on every render.** getWeekdayNames costs ~22 dayjs instances plus 7 locale-table formats, and it ran unmemoized inside Month — so it was paid on every hover frame, times numberOfColumns`. Now memoized on the locale, format and first-weekday.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/components@0.6.0 - @knitui/core@0.6.0 - @knitui/hooks@0.6.0

@knitui/graphics

  • Take the allocations out of the audio sampler and the Skia visualizer's paint The media store's design was already right — per-field channels, auto-tracked selectors, one FFT per painted frame — so this is a pass over the two paths that run at 60 Hz rather than at tick rate: the PCM sample bridge and the visualizer painter. The sampler did full peak/RMS work per frame, before the silence gate, and even when nothing was listening. The backend posts a ~2048-frame window per display frame for as long as sampling is on, and setSamplingEnabled is driven by mount effects that can outlive the visualizer that asked for it — so with no sampleUpdate listener at all, every frame still allocated a channel array (.map) plus a result object and walked every frame twice (peakOf then rmsOf, ≈245k typed-array reads/second on a stereo source), only for emitSample to then drop it at the silence gate. The handler now returns on hasListeners("sampleUpdate") as its first statement (the same gate timeUpdate already had), fuses peak and RMS into ONE pass over the frames (≈123k reads/second, byte-identical results — the per-channel clamps land exactly where peakOf/rmsOf applied them), and refills a reused channel list + envelope instead of allocating two objects per frame. AudioSampleData already documented its buffers as read-synchronously; that now explicitly covers the channel list too. mixChannels takes an optional result object for the same reason. Time labels subscribed to a raw float although `formatTime` floors to seconds. useAudioState(s => s.currentTime) fails Object.is on every tick, so the <Text> re-rendered at tick rate — 4 Hz on video, higher on native audio — and 3 of every 4 renders produced a byte-identical string. The five timecode labels (TimeCurrent and TimeDisplay on <Audio>, <Video> and <AudioPlaylist>) now select Math.floor(s.currentTime). The scrubbers keep the float — they need sub-second thumb positions. `useMediaSelector` allocated a `Proxy` and a sorted key signature per notification AND per render. Every store change per subscribed leaf built a fresh tracking Proxy in runTracked, a [...keys].map(String).sort().join() in register(), then a second Proxy + signature during the re-render and a third signature in the post-commit effect — ≈3 proxies and 3 sorted strings per leaf per tick. Snapshots are immutable, so the tracking proxy is now cached per snapshot identity in a WeakMap (one proxy serves every leaf that reads that snapshot, and it dies with it) and the tracked key set is compared by size + membership against the registered list, so the steady state allocates nothing. This is noise at the current 4 Hz tick and stops being noise the moment a caller drives position at frame rate. The visualizer built 2 Skia host objects per bar, per frame. Skia.XYWHRect + Skia.RRectXY each return a real host object — a JSI HostObject holding a C++ shared_ptr on native, a JsiSkRect wrapping a fresh Float32Array on web — so variant="mirror" at the default count=48 created and discarded 96 of them per painted frame, ≈5,800/second, on the UI thread. Both bindings also accept a plain object and copy out of it synchronously (JsiSkRect::fromValue / JsiSkRRect::fromValue, and their web twins), so the fill builder now reuses ONE mutable rect/rrect pair for the whole frame: 96 host objects per frame → 2 plain objects. The resulting SkRRect comes from the identical SkRRect::MakeRectXY call, and a zero radius takes addRect, which is what Skia's addRRect degenerates to for a radius-less rrect — the drawn path is unchanged, and a new suite records the exact op sequence (addRRect/addRect/ addCircle/moveTo/lineTo/close) with the values each call received to keep it that way. And it rebuilt the whole shape list as objects every frame to hand it to the builder in the next statement. A useDerivedValue published count fresh {kind,x,y,w,h,r} objects plus a fresh array into a SharedValue per frame (≈2,900 objects/second at the default count, plus another array whenever gain/floor/ reverse was set), read once by the two path worklets and thrown away. Each variant now also exists as a writer into a flat Float64Array that the component owns and reuses forever, and geometry + path building are fused into the two path worklets, so the intermediate never exists: the steady-state paint allocates nothing but the SkPath itself. strokeWidth no longer builds a count-length zero array and runs the whole variant to read one number either. The cost is one extra variant pass per frame (pure arithmetic into a buffer) in exchange for a SharedValue write, a mapper, and every allocation on the path. Float64Array rather than Float32Array so the coordinates round-trip exactly. VisualizerVariant, registerVisualizerVariant and the six exported variants are UNCHANGED — the object-shaped variants are now thin decoders over their writers (one implementation of each variant's geometry, so the two forms cannot drift), and a foreign variant is adapted by a shim that encodes its returned shapes, paying exactly the allocation it paid before. `<AudioMesh>` repainted a full-canvas fragment shader 60 times a second even with its drift frozen. It drove its uniforms from Skia's useClock(), whose frame callback runs for as long as the component is mounted; every tick dirtied the uniforms mapper, so prefers-reduced-motion (which pins the drift at t = 0) and speed={0} (documented as "freezes the orbit") both kept rebuilding uniforms and repainting pixel-identical output forever. The clock is now local and stopped whenever the drift is frozen — no writes, so no mapper run, so no repaint — and a frozen mesh repaints only when the AUDIO moves it. Its uniforms also go into buffers the component owns (buildMeshUniformsInto): 3 arrays + a [w, h] tuple + the record per frame → just the record, which has to stay fresh or reanimated skips the SharedValue write and <Shader> never sees the frame. buildMeshUniforms keeps its exact allocating shape for other callers, over the same single implementation of the math. Also, in the same spirit: - useVisualizerSource built a ${count}|${fftScale}|…|${bins} memo key on EVERY pushed FFT row (up to display rate) to prove nothing had changed; the mapper cache now lives in the reducer's closure, keyed by the settings, so only the pushed length is compared. - TypedEmitter.emit copied its listener Set into a fresh array on every emit — including per audio frame. The single-listener case (a visualizer on sampleUpdate, a scrubber on timeUpdate) now dispatches without the copy, with identical semantics: a listener it removes was already dispatched, and one it ADDS is still not delivered to in the same emit (which plain live iteration WOULD do — a Set grown during iteration yields the new entries; there is a test for exactly that). - The mic-stream reductions (frameFromTimeDomain, native useAudioStream) write the envelope straight into the frame/level object through a reused one-slot channel list: 3 allocations per captured buffer → 1 (the level object stays fresh — it is handed to onLevel and to React state). No public API changes in either package. Deferred: moving useLevelTransition's smoother from the JS thread onto the UI thread, and/or publishing its eased row as a ping-ponged typed array (today: 60 boxed 48-element arrays per second, each converted element-by-element into a reanimated serializable on native — a shared ArrayBuffer would skip both). The allocation counts are certain, but it touches the documented Reanimated-4 SharedValue rules, changes the public SharedValue<number[]> level type, and trades a fresh-array-per-frame guarantee for double buffering, so the win wants on-device measurement first.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/components@0.6.0 - @knitui/core@0.6.0

@knitui/mediaquery

  • Cut the shipped theme suite from 440 themes to 22, and stop the mediaquery hooks re-parsing / re-rendering per frame ## @knitui/core — 418 generated themes removed config/themes.ts called createThemes(...) without a componentThemes argument, so it inherited Tamagui's defaultComponentThemes — a 19-entry map that is itself @deprecated upstream ("component themes are no longer recommended"). Those entries cross-multiply with every scheme × palette, so the kit shipped 2 × 11 × 20 = 440 themes, all built eagerly at module scope on every app start and again by the Tamagui babel/static compiler. Measured against the kit's real inputs (interpreter-only, node --jitless): | | themes | theme keys | resolved Variables | heap | build | | ------ | ------ | ---------- | --------------------- | -------- | ------- | | before | 440 | 37,708 | 37,708 (2,822 unique) | 2,513 KB | 12.1 ms | | after | 22 | 1,870 | 1,870 (1,010 unique) | 208 KB | 2.8 ms | componentThemes: false is set both in the stock config/themes.ts and in createTheme(), so a consumer-built config has the same theme set as the shipped one (24 names there — the stock 22 plus the brand alias in both schemes). ### BREAKING for anyone naming a component theme Themes named <scheme>[_<palette>]_<Component> no longer exist. If you reference one by name — <Theme name="light_Button">, a themes override keyed dark_gray_Card, or a createTheme({ themeBuilder: { componentThemes: … } }) extension — it will no longer resolve and the nearest parent theme is used instead. The removed names are the 20 Tamagui defaults (Button, Card, Checkbox, Input, ListItem, Progress, ProgressIndicator, RadioGroupItem, SelectItem, SelectTrigger, SliderThumb, SliderTrack, SliderTrackActive, Switch, SwitchThumb, TextArea, Tooltip, TooltipArrow, TooltipContent) × 22 scheme/palette combinations. To get them back, pass your own map: ``ts createTheme({ themeBuilder: { componentThemes: { Card: { template: "surface1" } } } }); ` ### @knitui/components — offsets that were implicit are now explicit Nine of the kit's styled() names collided with that default map and were silently receiving a template offset. Every one of them has been PINNED so the rendered colour is unchanged — verified value-by-value across light, dark, light_blue and dark_red (100 probes, 0 differences): | component | template | change | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | ListItem | surface1 | none — the frame paints nothing, and surface* leaves $color1…$color12 and $color alone | | Progress | surface1 | none — already explicit $colorN | | Button | surface3 | variant="default" border pinned $borderColor$color7 (the only variant reading $borderColor) | | Card | surface1 | frame + Card.Section border pinned to $color2 / $color5 | | Checkbox | surface2 | unchecked box pinned to $color3 / $color6 | | SliderTrack | inverse | ramp steps written pre-mirrored ($color3$color10; child SliderBar $color9$color4) | | SliderThumb | inverse | pre-mirrored ($color1$color12, $color9$color4, hover $color10$color3; child SliderLabelBubble too) | | SwitchThumb | inverse | child SwitchThumbIndicator pre-mirrored ($color5$color8, on $color9$color4). The thumb itself uses $white, a token, so it was never inverted | | Tooltip | inverse | frame $color9$color4, Tooltip.Text $color1$color12 | Two things worth flagging to whoever owns the visual design: 1. The four inverse collisions were **reversing the entire $color1…$color12 ramp** under those subtrees, not offsetting one step. So Tooltip was authored $color9 fill + $color1 label (the kit's canonical filled pairing) and actually painted step 4 + step 12; the slider's track/bar/thumb were likewise inverted. The pins preserve the shipped pixels, but they codify an appearance that came from a deprecated Tamagui default rather than a design decision. Un-mirroring them (back to $color9/$color1 etc.) is a deliberate follow-up. 2. **Descendants** of those nine frames no longer inherit the offset theme. A component placed inside a Card used to resolve $background one ramp step up ($color2); it now gets the page value ($color1). Only the nine frames' own painted surfaces are pinned — arbitrary user content inside them shifts by one step (or un-inverts, inside the Slider/Tooltip parts). config/OVER_MEDIA also moved to its own module (config/over-media.ts) so importing those five scrim literals no longer drags in the whole theme generation. No export surface change. ## @knitui/mediaquery — no per-render parsing, 3N listeners → 3 - **matchesQuery no longer re-parses on every call.** The native useMediaQuery evaluated its query on the render path, and a string query re-ran the full parser each time: for "(min-width: 768px) and (orientation: landscape)" that is ~6 regex executions plus ~10 allocations, per render, per hook instance — so a 50-row list did ~300 regex ops per render pass to produce 50 identical booleans. Parses are now memoised in a bounded module-level cache (parseMediaQuery hands out copies, so a caller can't poison it). - **One shared environment store on native.** Each call site used to register THREE native subscriptions (Dimensions, Appearance, AccessibilityInfo.reduceMotionChanged) plus an isReduceMotionEnabled() promise — 20 call sites meant 60 live listeners. Worse, every handler did setEnv(prev => ({ ...prev, … })), always a NEW object, so one rotation re-rendered every instance even though the boolean each derives almost never flips. There is now one store with three listeners total, read through useSyncExternalStore whose snapshot is the resolved **boolean**, so React bails per component unless that component's answer actually changed. - **useBreakpoint renders on band crossings, not resize pixels.** It was backed by useViewportSize, whose untreated resize listener allocates a fresh { width, height } ~60×/s during a window drag, forcing a re-render at every call site before the band was even derived — though resolveBreakpoint yields only 8 values across 7 thresholds. mediaquery now owns a viewport store that wakes subscribers only when the band changes, and the hook's snapshot is the BreakpointKey string. (@knitui/hooks' useViewportSize is untouched — it has other consumers that want the pixel width.) - **useSystemColorScheme** builds its MediaQueryList once at module scope instead of constructing a live, document-registered query object inside getSnapshot (which React calls per render and per store-change check, twice over in StrictMode) and again in subscribe. No public API change in @knitui/mediaquery; useBreakpoint keeps its SSR contract (the server snapshot and first hydrating render both resolve the MediaQueryProvider` seed).
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/core@0.6.0 - @knitui/hooks@0.6.0

0.5.0

@knitui/core

  • Align published dependency ranges with Expo SDK 57 The SDK pins exact versions for its native modules, and several of our published ranges had drifted from that set. Consumers on SDK 57 were resolving versions the SDK's prebuilt binaries don't expect, which fails at runtime rather than at build time. - @knitui/components: expo-image ~57.0.0~57.0.1 - @knitui/core: @tamagui/* ^2.3.0^2.4.6 - @knitui/media: expo-audio ~57.0.0~57.0.2, expo-video ~57.0.0~57.0.1 - @knitui/plugins: @tamagui/babel-plugin ^2.3.0^2.4.6 @knitui/graphics is a minor rather than a patch because its @shopify/react-native-skia peer is an exact pin and moves 2.6.62.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to 2.6.6 will need to move to 2.6.2 to satisfy the peer.

@knitui/components

  • Align published dependency ranges with Expo SDK 57 The SDK pins exact versions for its native modules, and several of our published ranges had drifted from that set. Consumers on SDK 57 were resolving versions the SDK's prebuilt binaries don't expect, which fails at runtime rather than at build time. - @knitui/components: expo-image ~57.0.0~57.0.1 - @knitui/core: @tamagui/* ^2.3.0^2.4.6 - @knitui/media: expo-audio ~57.0.0~57.0.2, expo-video ~57.0.0~57.0.1 - @knitui/plugins: @tamagui/babel-plugin ^2.3.0^2.4.6 @knitui/graphics is a minor rather than a patch because its @shopify/react-native-skia peer is an exact pin and moves 2.6.62.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to 2.6.6 will need to move to 2.6.2 to satisfy the peer.
  • Updated dependencies[4f7830c] - @knitui/core@0.5.0 - @knitui/hooks@0.5.0 - @knitui/icons@0.5.0

@knitui/hooks

  • Updated dependencies[4f7830c] - @knitui/core@0.5.0

@knitui/dates

  • Updated dependencies[4f7830c] - @knitui/components@0.5.0 - @knitui/core@0.5.0 - @knitui/hooks@0.5.0

@knitui/graphics

  • minor Align published dependency ranges with Expo SDK 57 The SDK pins exact versions for its native modules, and several of our published ranges had drifted from that set. Consumers on SDK 57 were resolving versions the SDK's prebuilt binaries don't expect, which fails at runtime rather than at build time. - @knitui/components: expo-image ~57.0.0~57.0.1 - @knitui/core: @tamagui/* ^2.3.0^2.4.6 - @knitui/media: expo-audio ~57.0.0~57.0.2, expo-video ~57.0.0~57.0.1 - @knitui/plugins: @tamagui/babel-plugin ^2.3.0^2.4.6 @knitui/graphics is a minor rather than a patch because its @shopify/react-native-skia peer is an exact pin and moves 2.6.62.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to 2.6.6 will need to move to 2.6.2 to satisfy the peer.
  • Updated dependencies[4f7830c] - @knitui/components@0.5.0 - @knitui/core@0.5.0

@knitui/mediaquery

  • Updated dependencies[4f7830c] - @knitui/core@0.5.0 - @knitui/hooks@0.5.0

0.4.3

@knitui/map

  • SvgImage no longer gives up on a raster whose surface has not painted yet. Exhausting the eager capture window ended the job without ever calling onCapture, and nothing re-requests a raster — so the store slot stayed unresolved forever, no MapLibre image was registered, and every SymbolLayer drawing that icon silently drew nothing for the rest of the session, with no log and no retry. The eager window assumed the surface was racing its first draw and would win within ~80 frames. A surface that is not being drawn at all breaks that assumption rather than losing the race: a map mounted offscreen to warm its GL context, a screen frozen by freezeOnBlur, an Android view pruned before it painted. All of those resolve later, at which point the next attempt would have succeeded — so ending the job turned a transient condition into a permanent one. The window is now only a change of pace. Past it a job hands its concurrency slot back and keeps polling at ~1s until it succeeds or its surface unmounts; returning the slot matters, because surfaces that are not painting would otherwise hold every slot forever and starve one that would succeed immediately. A dev-only console.warn (typeof-guarded, so a bundler that does not define __DEV__ cannot turn a slow icon into a crash) now names the likely cause on the transition to the patient cadence.

0.4.2

@knitui/map

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.

0.4.1

@knitui/map

  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.

@knitui/media

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0 - @knitui/components@0.8.0 - @knitui/hooks@0.8.0 - @knitui/icons@0.8.0

0.4.0

@knitui/components

  • minor Add VirtualList and reanimated-native scroll offsets - `VirtualList` — a new windowed list for large datasets. Renders only the visible slice (plus overscan) over ScrollArea, supports variable row heights via per-type average sizing, and exposes an imperative handle (scrollToIndex / scrollToOffset). One cross-platform component file over a platform-free measurement engine. - `ScrollArea` — new scrollValueX / scrollValueY props mirror the live scroll offset into caller-owned reanimated shared values on the UI thread, so parallax and collapsing-header animations run with no JS-thread round-trip. Native only; the web ScrollArea ignores them. - `UnstyledButton` — now carries the same reduced-motion-aware press-scale affordance as Button / ActionIcon / Chip, so custom controls built on it no longer read as dead on press. Descendant pressStyle merges with it. - `Image` — fix a crash when passing transition on web. expo-image's numeric transition collided with Tamagui v2's reserved animation prop, which marked the image animated and made the web driver call getComputedStyle on a non-DOM host. The value is now forwarded to the backend under an internal alias.
  • @knitui/core@0.4.0
  • @knitui/hooks@0.4.0
  • @knitui/icons@0.4.0

@knitui/hooks

  • @knitui/core@0.4.0

@knitui/dates

  • Updated dependencies[5b5a3e0] - @knitui/components@0.4.0 - @knitui/core@0.4.0 - @knitui/hooks@0.4.0

@knitui/map

  • minor Stop the map doing per-render and per-frame work, and move the bundled styles off the barrel A pass over every hot path in @knitui/map, cross-checked against the maplibre-gl 5.24 and maplibre-react-native 11.3 sources — most of the cost landed _inside_ those two, on props this package handed them. `GeoJSONSource` re-serialized its whole FeatureCollection on every ancestor render (native). Upstream's GeoJSONSource stringifies in its render body (data={typeof data === "string" ? data : JSON.stringify(data)}). It is memo'd, but that memo could never hold through our wrapper: onPress was an inline arrow and children is the consumer's inline JSX. So _any_ ancestor re-render — including a setState from onRegionIsChanging, which fires up to 30×/s — re-stringified every feature (~1–3 MB for a 4000-point collection) on the JS thread, shipped a new string prop through Fabric, and made the native side re-parse, re-index and re-cluster it. data did not have to have changed. The wrapper now serializes once per data identity and hands upstream a string, so its ternary short-circuits to the same identity and Fabric diffs the prop to nothing; onPress is stable over a props ref. Every filterless layer forced a source reload at mount (web). useWebLayer called map.setFilter(id, filter ?? null). maplibre's guard is deepEqual(layer.filter, filter), layer.filter is undefined, and deepEqual bottoms out at a === b — so null compared FALSE and took the clearing branch: layer.setFilter(undefined) + _updateLayer(layer), which sets _updatedSources[source] = 'reload' and calls tileManager.pause(). Every layer without a filter prop therefore re-requested and re-tessellated its source's tiles immediately after being added — three times over for the standard three-layer clustered pattern, showing up as a slow first paint plus a flash of missing markers. The call is now skipped entirely when the filter is and was absent, and clears with undefined rather than null. Paint/layout were re-applied property-by-property, undiffed, on every render (web). The effect's deps were object identities and the public API is an inline camelCase style object, so it fired every render and looped setPaintProperty / setLayoutProperty over every key. maplibre does guard with deepEqual, but only after _checkLoaded(), getLayer() and layer.getPaintProperty(name) — and that last one is clone(this._values[name].value.value). So each property cost a deep clone plus a deep compare of its whole expression tree, every render, across all nine web layer types; a ["step", ["get","point_count"], …] or a 200-branch ["match", …] icon-image paid it in full. useWebLayer now diffs before calling into maplibre and memoizes resolvePaintLayout / the add-config. The dropped-key reset loop is unchanged. Fresh paint/layout objects defeated the native `Layer`'s memo. Each of the eight native layer components allocated new paint/layout per render, and upstream's guard is useMemo(…, [props]) where props is a fresh rest-spread — so it never hits no matter what we pass. Every render therefore ran mergeStylePropstransformStyle (a processColor per colour, a new BridgeValue(...).toJSON() walk over every expression array) and produced a new reactStyle prop that the native layer re-applied wholesale. A new shared useNativeLayerProps hook memoizes the resolved paint/layout _and_ the whole props object on the real inputs. Upstream's memo stays useless, but the native prop identity stops changing, so Fabric diffs the layer to nothing. `map.getStyle()` — a deep clone of the entire style — ran on every mousemove (web). The hover cursor hit-test derived its layer list from getStyle(), i.e. Style.serialize()_serializeByIds(this._order, returnClone=true) → a clone() of every serialized layer plus mapObject(tileManagers, s => s.serialize()). On a Positron/Voyager basemap (~130 layers, each with paint and layout expressions) that is thousands of allocations per pointer move — and it only ran when an interactive source existed, i.e. exactly for the 4000-point onPress consumer. The layer registry already knows each layer's sourceId, so the list is now derived from it and cached, invalidated by a registry revision counter and a styledata epoch. The hit-test itself is coalesced to one per animation frame and skipped while map.isMoving(). A full view-state event object plus timer churn on every raw `move` event (web). makeViewStateChangeEvent ran _before_ the 32 ms throttle check, so every move (60+/s) did a getBounds() (four corner unprojections + a LngLatBounds alloc), a getCenter(), getZoom/getBearing/getPitch and two object allocations — even for consumers with no region handlers at all. Each move also did a clearTimeout plus a fresh 500 ms setTimeout. Now: early-out when no region handler is registered, the event is built lazily _after_ the throttle gate, and the trailing debounce is only armed when onRegionDidChange exists. The native side (where the event comes from native, so only the timer churn applied) got the same treatment. onRegionIsChanging is documented as the thing not to setState from unthrottled, since that is the amplifier for everything above. The rasterizer leaked offscreen surfaces and threw away resolved bitmaps. resolve() stored the data URI but never took the slot out of the render snapshot, so RasterizerHost kept every <SvgXml> surface mounted for the map's whole lifetime — on native those are real view trees with collapsable={false} that Android walks on every layout pass. Conversely release() deleted the slot _including_ its resolved bitmap, so unmounting and remounting an icon (a filter toggle) re-rasterized from scratch and drove a removeImage/addImage round trip — and removing an image a symbol layer references forces MapLibre to redo symbol placement. Surfaces now unmount once their key resolves (never before, so runCapture's retry loop still has something to snapshot), and resolved URIs are kept in a 64-entry LRU keyed by the existing content key, so a remount is free. Up to 30 rAF-spaced native view snapshots per icon during the map's first frames. runCapture retried toDataURL once per animation frame for up to 30 frames, with the attempt counter shared between the "ref not attached" and "empty bytes" cases. With ~30 category icons that is up to 900 native view snapshots in the ~500 ms right after mount — exactly when MapLibre is loading its first tiles and doing initial symbol placement. Captures are now capped at 3 in flight (the rest drain from a queue) and back off geometrically (1, 2, 4, 8, 8 … frames), which cuts snapshots per icon from 30 to 12 while making the retry _window_ longer (~80 frames vs 30). Also: Camera was JSON.stringify-ing its camera stop in the render body on every render (now a hand-rolled scalar/tuple compare), MapView was stringifying attribution every render (now memoized), and the setStyle effect's JSON.stringify(mapStyle) — safe only because it was keyed on mapStyle identity, and a ~485 KB serialization per render for anyone passing an inline or derived style — is now a structural compare that allocates nothing. ## Breaking: the bundled styles moved to a subpath The nine bundled MapLibre styles are no longer re-exported from the root barrel: ``diff - import { positronStyle } from "@knitui/map"; + import { positronStyle } from "@knitui/map/styles"; + // or, for exactly one style: + import { positronStyle } from "@knitui/map/styles/positronStyle"; ` They total 496,951 bytes / 20,858 lines of nested object literals. Following every runtime (non type-only) relative import out of the barrel: it reached **65 modules / 623.9 KB**, of which the styles were 77.9%. It now reaches **55 modules / 139.2 KB** — a 4.5× smaller graph. That mattered because the package src-ships and Metro does not tree-shake, and because a re-export is export *, the CJS interop Babel/Metro generate require()s the module at barrel eval — so every app that rendered <Map> with its own style URL constructed ~500 KB of literals before its first frame. Nothing else about the styles changed: same names, same values, same @knitui/map/styles` index.

@knitui/media

  • minor useAudioPlaylistController exposes the single-track player underneath the queue The hook now returns a third field, player: the AudioController slot the playlist drives, stable for the hook's lifetime. It exists for the surfaces that belong to the PLAYER rather than the queue, which the playlist contract cannot express. The motivating one is spectrum sampling — useAudioSpectrum needs setSamplingEnabled and sampleUpdate, which live on the single-track controller only, because a playlist has no notion of PCM. Before this there was no supported way to reach the sampler from a playlist at all: the facade is private on the controller and its slot id is an internal useId no caller could pass to getFacade, so a visualizer over a queue was simply not buildable. ``tsx const { controller, store, player } = useAudioPlaylistController({ sources }); useAudioSpectrum(player, { onFrame: (bands) => viz.current?.push(bands) }); ` Playback still goes through controller: transport calls on player` move the slot without telling the queue, and the two snapshots then disagree.
  • Stop web video silently dropping a play() made before its view exists expo-video's web backend applies play() by iterating the HTMLVideoElements currently mounted into the player, so a call made while no view is attached iterates an empty set: no throw, no error status, no retry, nothing to observe. In a kit whose surface is a single teleported element that is not an edge case — it is what happens on a cold mount, where the call beats the view's own effect, and on every switch between players in the shared session, where the surface is destroyed and a NEW element is built for the incoming player's frame. autoPlay was the plainest casualty: it ran in the constructor, before any view could exist. The controller now tracks a standing play INTENT — set by play()/replay(), cleared by pause() and by dispose() — and attachView re-applies it, which is what makes both cases work. It is idempotent by construction: player.play() on an already-playing player is a no-op on both backends, and a paused intent re-applies nothing. attachView(null) also now marks the snapshot as not playing. Detaching is the only moment the controller learns its element has been handed to another player, and the outgoing element takes its playback with it while never reporting a pause — it is unmounted from the player first, so its events no longer count. The snapshot was left claiming playing: true over a torn-down element, and every consumer that branches on it — a play/pause toggle most of all — then did the opposite of what it should.
  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[edcec97]
  • Updated dependencies[15a329c]
  • Updated dependencies[59d065b] - @knitui/components@0.7.0 - @knitui/core@0.7.0 - @knitui/hooks@0.7.0 - @knitui/icons@0.7.0

@knitui/graphics

  • Updated dependencies[5b5a3e0] - @knitui/components@0.4.0 - @knitui/core@0.4.0

@knitui/mediaquery

  • @knitui/core@0.4.0
  • @knitui/hooks@0.4.0

0.3.7

@knitui/carousel

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0 - @knitui/components@0.8.0 - @knitui/hooks@0.8.0 - @knitui/icons@0.8.0

@knitui/sheet

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0 - @knitui/components@0.8.0 - @knitui/hooks@0.8.0 - @knitui/icons@0.8.0

0.3.6

@knitui/carousel

  • Show a grabbing cursor while a mouse drag is actually under way The transform-mode track is dragged by RNGH's Gesture.Pan() (useDragGesture, shared with native), which knows nothing about cursors — so a mouse user got no feedback at all that the rail was moving with the pointer. A new web-only useDragCursor paints grabbing once travel passes the same 5px threshold the drag itself uses, and restores whatever the carousel's own styling asked for on release. Deliberately no idle grab hint: the cursor is untouched until a drag is genuinely in progress, so hovering a carousel and plain clicks on a slide look exactly as they did. The move/release listeners sit on the document rather than the host, because a drag routinely travels and ends outside the carousel and a pointerup we never heard would leave the cursor stuck on grabbing — as would unmounting mid-drag, which the cleanup now also covers. scrollMode="native" keeps getting its cursor from useDragScroll (it drags scrollLeft, not the pan gesture), which is brought in line with the same rule: it no longer stamps an idle grab on the track, and it restores the original cursor on release instead of resetting to grab — previously it overwrote the track's own cursor for the lifetime of the component.
  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[edcec97]
  • Updated dependencies[15a329c]
  • Updated dependencies[59d065b] - @knitui/components@0.7.0 - @knitui/core@0.7.0 - @knitui/hooks@0.7.0 - @knitui/icons@0.7.0

@knitui/sheet

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[edcec97]
  • Updated dependencies[15a329c]
  • Updated dependencies[59d065b] - @knitui/components@0.7.0 - @knitui/core@0.7.0 - @knitui/hooks@0.7.0 - @knitui/icons@0.7.0

0.3.5

@knitui/carousel

  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.
  • Updated dependencies[487dce4]
  • Updated dependencies[ffc254e]
  • Updated dependencies[ffb4133]
  • Updated dependencies[caa2d7e] - @knitui/components@0.6.1 - @knitui/core@0.6.1 - @knitui/hooks@0.6.1 - @knitui/icons@0.6.1

@knitui/sheet

  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.
  • Updated dependencies[487dce4]
  • Updated dependencies[ffc254e]
  • Updated dependencies[ffb4133]
  • Updated dependencies[caa2d7e] - @knitui/components@0.6.1 - @knitui/core@0.6.1 - @knitui/hooks@0.6.1 - @knitui/icons@0.6.1

0.3.4

@knitui/carousel

  • Cut the per-frame and per-slide work the carousel does while scrolling Six verified hot paths, all in the scroll/fling loop: `modeConfig` was memoized by object identity. modeConfig is documented (and used everywhere) as an inline literal, so it was a fresh object every render and useLayout rebuilt the layout worklet every render. That closure is a dependency of every mounted slide's useAnimatedStyle, so Reanimated tore down and re-installed the style mapper of EVERY mounted slide on every render, the slide React.memo never held, and on web the painter ran a full paintAll() over all entries. It is now compared shallowly and collapsed to a stable reference, so an unchanged config produces the same worklet. The public prop is unchanged — keep passing it inline. Every mounted slide had its own JS-thread listener on `offset` (web). scrollMode="native" mounts all slides — count * 3 in loop mode — and each one subscribed to offset separately. A 30-item looped rail therefore turned a single offset.value write into 90 JS callbacks and 90 shared-value writes, on the main thread, for every scroll event the browser fired. There is now ONE listener in the track over a registered-slot map (the same design the web painter uses), computing the scroll position once: 90 callbacks → 1, 90 writes stay but cost one loop. The tracks are memoized. Track / NativeTrack re-rendered on every settled index change (once per page crossed during a fling), rebuilding the element — and re-running getItem / keyExtractor — for every mounted slide. Both are now React.memo; combined with the modeConfig fix every prop is identity-stable on such an internal re-render, so the slides are left alone. useResolvedSource now folds the source version into its accessor identity, which is what lets a memoized track still notice that an async page has landed. Slide content is deliberately NOT insulated from a parent re-render: making renderItem's identity permanently stable (a render-time ref proxy) would hold the memo across parent renders too, but it silently freezes slide content whenever an inline renderItem closes over changed state — the classic FlatList extraData bug. Two tests now guard that in both scroll modes. `onProgressChange`'s callback form is coalesced — a cadence change. The UI-thread reaction hopped to JS on every single frame while dragging or flinging; a consumer that puts the value in React state (the natural thing to write) paid a full render per frame, which is guaranteed jank on Android. The callback form is now throttled to ~30 Hz (one hop per 32 ms) and is always flushed with the exact final value when the scroll settles, so "where did we land" logic is unaffected — but a callback wired straight to an animation will now see roughly half as many samples. Pass a SharedValue instead for per-frame fidelity: it is written on the UI thread with no hop, is unthrottled, and is now documented as the preferred form. Pagination derived selection per dot. Each dot installed its own useAnimatedReaction over progress, which the carousel writes every frame — one mapper evaluation per dot per frame on the UI thread, linear in dot count. The selected index is now derived ONCE per row (useSelectedIndex) and handed down as a boolean; a 10-dot row goes from 10 subscriptions to 1. Each dot keeps its own declarative transition, and the fill variants (Pagination.Basic / .Custom) keep their per-dot fill useAnimatedStyle. Minor behaviour change: at the loop seam the fill variants now select dot 0 (rounding wraps into real-item space) instead of selecting no dot at all. A side effect inside a `setState` updater. Track assigned its travel direction inside the setCenter reducer, which React may invoke twice (StrictMode / concurrent replay); the second pass compared against the already-advanced value and could record the wrong direction, costing a mis-aimed prefetch. It is now derived outside the updater against a ref. Also corrected useSharedValueListener's docstring, which claimed addListener works "on native and on web". In Reanimated 4 a listener can only be added on the UI runtime, so an ungated call from the JS thread throws on native — every call site is gated to web or lives in a .web file, and the docstring now says so. The 3D layout worklets allocated a throwaway array per slide per frame. coverflow, flip and cube built their transform as [...persp, …], which allocates an intermediate persp array and then copies it element by element into the real one — multiplied by the mounted window size times the frame rate, on the UI thread on native, where the resulting GC pressure is visible as fling jank. The array is now built in one shot per branch, and the perspective > 0 test and the axis/vertical choice are hoisted to closure-creation. The returned array stays fresh per call on purpose: reanimated converts it on assignment and it must not be reused or mutated. The web painter rewrote four style properties per slide per painted frame. opacity, zIndex, transformOrigin and backfaceVisibility were re-assigned every paint even though the default normalLayout emits none of them — four wasted CSSOM writes per slide per frame, each dirtying the element's inline style. They are now diffed against a per-entry cache, so the steady state is zero writes. transform is deliberately still written unconditionally: Item.web also writes it via initialStyle, so a cached value could read as already-applied and strand the slide at a stale transform. Each pagination dot re-rendered on every page change. Only one dot's selected flips, but every dot rebuilt its useReducedTransition and its Tamagui CarouselDot. Dot is now React.memo'd, with the row stabilising onPress (every caller writes it inline). renderDot is deliberately NOT stabilised — an inline renderer closes over its own state, and freezing it is the FlatList extraData trap.
  • Stop the icon barrel from being pulled into every component @knitui/components dragged all ~6.1k icon modules into any app that imported a single component. The kit SRC-SHIPS, so Metro compiles what it resolves and does NOT tree-shake: one line in internal/ControlIconProvider.tsximport { IconProvider } from "@knitui/icons" — resolved the root barrel (packages/icons/src/index.ts, 378 KB / 6,153 export lines), and because that module is reachable from Button, Chip, Accordion and friends, every glyph became part of the graph. Walking the import graph from each package entry, honouring exports conditions and .web/.native order: | entry | before | after | | -------------------- | ------------------------ | ---------------------- | | @knitui/components | 6,490 modules / 4,896 KB | 344 modules / 1,635 KB | | @knitui/sheet | 6,506 modules / 4,960 KB | 360 modules / 1,700 KB | | @knitui/carousel | 6,541 modules / 5,068 KB | 398 modules / 1,808 KB | | @knitui/media | 6,530 modules / 5,056 KB | 384 modules / 1,796 KB | That is −94.7% modules and −3,261 KB of source off the components graph, and it lands on Metro cold start, on every clear-cache rebuild, and on the JS bundle itself for consumers whose bundler cannot shake a src-shipped barrel. `@knitui/icons` gains two subpath exports (the minor): - @knitui/icons/contextIconProvider, useIconContext and their types - @knitui/icons/typesIconProps, IconNode, IconComponent, IconType Per-glyph deep imports (@knitui/icons/IconCheck) already resolved through the existing ./Icon* wildcard; the provider was the one thing that had no subpath and therefore forced the barrel. Nothing was removed — the root barrel still exports everything it did. Internal import rewrites (patch, no API change) across the shipped source of @knitui/components (ControlIconProvider, Accordion, Checkbox/CheckIcon, Chip, CloseButton, Combobox, Stepper), @knitui/carousel (Carousel) and @knitui/media (the audio/video chrome + LiveAudioMeter), each now naming the glyph module it actually uses. An ESLint no-restricted-imports guardrail in eslint.config.base.mjs blocks the bare @knitui/icons / @knitui/emoji specifier in shipped src/** so this cannot regress; stories, tests and the private @knitui/demo galleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: - sideEffects was missing from @knitui/media, @knitui/sheet and @knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it. media and sheet are declared sideEffects: false (audited: their only module-scope statements are pure const initialisers and a displayName assignment — the one registerProcessor() call in media lives inside an AudioWorklet source string, not in the module). plugins lists just its babel-plugin entry, which really does mutate process.env.TAMAGUI_IGNORE_BUNDLE_ERRORS on load. - @knitui/plugins/next-plugin now sets experimental.optimizePackageImports for @knitui/icons, @knitui/emoji, @knitui/components, @knitui/dates and @knitui/map, so every Next consumer inherits it instead of having to know. Next has to build a barrel's full module graph before it can shake it, and transpilePackages means it compiles the kit's source to do so — with this, a bare-barrel glyph import in app code is rewritten to that glyph's own module and the other 6,145 files are never compiled. Any experimental the app already set is merged, not replaced, and its own optimizePackageImports entries are kept.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/components@0.6.0 - @knitui/icons@0.6.0 - @knitui/core@0.6.0 - @knitui/hooks@0.6.0

@knitui/sheet

  • Stop the icon barrel from being pulled into every component @knitui/components dragged all ~6.1k icon modules into any app that imported a single component. The kit SRC-SHIPS, so Metro compiles what it resolves and does NOT tree-shake: one line in internal/ControlIconProvider.tsximport { IconProvider } from "@knitui/icons" — resolved the root barrel (packages/icons/src/index.ts, 378 KB / 6,153 export lines), and because that module is reachable from Button, Chip, Accordion and friends, every glyph became part of the graph. Walking the import graph from each package entry, honouring exports conditions and .web/.native order: | entry | before | after | | -------------------- | ------------------------ | ---------------------- | | @knitui/components | 6,490 modules / 4,896 KB | 344 modules / 1,635 KB | | @knitui/sheet | 6,506 modules / 4,960 KB | 360 modules / 1,700 KB | | @knitui/carousel | 6,541 modules / 5,068 KB | 398 modules / 1,808 KB | | @knitui/media | 6,530 modules / 5,056 KB | 384 modules / 1,796 KB | That is −94.7% modules and −3,261 KB of source off the components graph, and it lands on Metro cold start, on every clear-cache rebuild, and on the JS bundle itself for consumers whose bundler cannot shake a src-shipped barrel. `@knitui/icons` gains two subpath exports (the minor): - @knitui/icons/contextIconProvider, useIconContext and their types - @knitui/icons/typesIconProps, IconNode, IconComponent, IconType Per-glyph deep imports (@knitui/icons/IconCheck) already resolved through the existing ./Icon* wildcard; the provider was the one thing that had no subpath and therefore forced the barrel. Nothing was removed — the root barrel still exports everything it did. Internal import rewrites (patch, no API change) across the shipped source of @knitui/components (ControlIconProvider, Accordion, Checkbox/CheckIcon, Chip, CloseButton, Combobox, Stepper), @knitui/carousel (Carousel) and @knitui/media (the audio/video chrome + LiveAudioMeter), each now naming the glyph module it actually uses. An ESLint no-restricted-imports guardrail in eslint.config.base.mjs blocks the bare @knitui/icons / @knitui/emoji specifier in shipped src/** so this cannot regress; stories, tests and the private @knitui/demo galleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: - sideEffects was missing from @knitui/media, @knitui/sheet and @knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it. media and sheet are declared sideEffects: false (audited: their only module-scope statements are pure const initialisers and a displayName assignment — the one registerProcessor() call in media lives inside an AudioWorklet source string, not in the module). plugins lists just its babel-plugin entry, which really does mutate process.env.TAMAGUI_IGNORE_BUNDLE_ERRORS on load. - @knitui/plugins/next-plugin now sets experimental.optimizePackageImports for @knitui/icons, @knitui/emoji, @knitui/components, @knitui/dates and @knitui/map, so every Next consumer inherits it instead of having to know. Next has to build a barrel's full module graph before it can shake it, and transpilePackages means it compiles the kit's source to do so — with this, a bare-barrel glyph import in app code is rewritten to that glyph's own module and the other 6,145 files are never compiled. Any experimental the app already set is merged, not replaced, and its own optimizePackageImports entries are kept.
  • Stop overlays, scrolling and looping animations from doing per-frame work A pass over every hot path that runs while something is moving — a scroll, a drag, an open/close animation — removing work that ran once per frame and did not need to. Floating overlays (web)autoUpdate's scroll/resize/element-resize triggers are now coalesced into one requestAnimationFrame. The capture-phase scroll listener fires for every scrollable ancestor in the app, and each trigger ran a full getBoundingClientRectcomputePositionsetState cycle: layout reads interleaved with the previous pass's style writes, i.e. a forced synchronous reflow per event, several per frame. The first position is still computed synchronously, so nothing flashes at an un-positioned origin. The repositioning bail-out also stopped JSON.stringify-ing both middleware-data bags (twice per pass, per open overlay) in favour of a shallow compare. `ScrollArea` — the scrollbar auto-hide no longer crosses the JS/UI boundary every frame on native: type="hover"/"scroll" (the default) needs only a boolean plus a trailing timer, so its runOnJS hop is rate-limited on the UI thread, while a real consumer (onScrollPositionChange, onScrollEnd, the reach callbacks, stickToBottom, shadows) still gets every sample. The thumb's per-frame animated style carries a transform ONLY — the hover-grow inset moved into its own style — so a scroll no longer pushes layout props (left/right/height) through Yoga on every frame. Edge fades (shadows) keep four booleans in state instead of mirroring the raw offset, so a fling re-renders at most once per edge crossing rather than once per frame. The web ResizeObserver is created once and its observations reconciled per commit, instead of being torn down and rebuilt over every child whenever the child count changed — which, for a windowed list inside a ScrollArea, happened mid-fling on every window advance. Looping animationsSkeleton, Loader, Progress and Indicator declare their loops unconditionally for stable hook order but render only some of them; every undisplayed loop was still scheduled, as a live compositor animation on web and a withRepeat re-evaluating a worklet every frame on native. A Loader paid for four, a static Indicator and a non-animate Skeleton for one each. Loops are now scheduled only when they actually animate. `useReducedMotion` — one shared subscription per app instead of one matchMedia / AccessibilityInfo listener per component (nearly every animated part in the kit calls it), and the correct value on first render, removing a mount-time re-render per instance. `useElementSize` — bails when a re-measure reports the size it already had, so a ResizeObserver/onLayout re-fire from a visibility flip or a sibling's reflow no longer re-renders Collapse/Spoiler/Marquee content. `Sheet` — the close watcher's gate is evaluated on the UI thread, so a dragging or springing panel stops waking the JS thread on every frame with an offset the subscriber immediately discards. `Sheet`'s pan gesture was rebuilt on every render. animationConfig is a flat record that every call site writes inline, and it is a dependency of useSheetDrag's useMemo — so a fresh identity rebuilt the Gesture.Pan() object each render, and RNGH responds by tearing down and re-attaching the native gesture handler. Mid-drag, that is a dropped gesture. It is now collapsed to a stable reference while its values are unchanged, via a new internal useStableRecord (with tests) — the sibling of carousel's modeConfig stabilisation and map's useStableStyleValue. withAnimation/gestureConfig are deliberately left alone: they are functions consumed inside worklets, where a JS-thread ref wrapper would break worklet semantics. No public API changes; useLoopingAnimation (internal) gained an enabled option.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/components@0.6.0 - @knitui/icons@0.6.0 - @knitui/core@0.6.0 - @knitui/hooks@0.6.0

0.3.3

@knitui/carousel

  • Updated dependencies[4f7830c] - @knitui/components@0.5.0 - @knitui/core@0.5.0 - @knitui/hooks@0.5.0 - @knitui/icons@0.5.0

@knitui/media

  • Point every types entry at the built declarations (lib/typescript/*.d.ts) instead of at the shipped TypeScript source. These packages ship their source and resolve it at runtime (source, react-native and default all still point into src), but types pointed there too — so a consumer's tsc typechecked the kit's raw source as part of their own build. That is slow, and it surfaces errors that depend on the consumer's own compiler settings, since skipLibCheck does not apply to .ts source files. Resolving types to real .d.ts files makes declaration handling both faster and inert. @knitui/icons also adds lib/typescript to files; its declarations were previously built but never published, so the new types path would not have existed in the tarball. @knitui/emoji deliberately keeps types on its source: its per-emoji modules ship as pre-generated .js/.d.ts pairs inside src, which tsc does not re-emit, so its built barrel cannot resolve them.
  • Updated dependencies[487dce4]
  • Updated dependencies[ffc254e]
  • Updated dependencies[ffb4133]
  • Updated dependencies[caa2d7e] - @knitui/components@0.6.1 - @knitui/core@0.6.1 - @knitui/hooks@0.6.1 - @knitui/icons@0.6.1

@knitui/sheet

  • Updated dependencies[4f7830c] - @knitui/components@0.5.0 - @knitui/core@0.5.0 - @knitui/hooks@0.5.0 - @knitui/icons@0.5.0

0.3.2

@knitui/carousel

  • Updated dependencies[5b5a3e0] - @knitui/components@0.4.0 - @knitui/core@0.4.0 - @knitui/hooks@0.4.0 - @knitui/icons@0.4.0

@knitui/media

  • Stop the icon barrel from being pulled into every component @knitui/components dragged all ~6.1k icon modules into any app that imported a single component. The kit SRC-SHIPS, so Metro compiles what it resolves and does NOT tree-shake: one line in internal/ControlIconProvider.tsximport { IconProvider } from "@knitui/icons" — resolved the root barrel (packages/icons/src/index.ts, 378 KB / 6,153 export lines), and because that module is reachable from Button, Chip, Accordion and friends, every glyph became part of the graph. Walking the import graph from each package entry, honouring exports conditions and .web/.native order: | entry | before | after | | -------------------- | ------------------------ | ---------------------- | | @knitui/components | 6,490 modules / 4,896 KB | 344 modules / 1,635 KB | | @knitui/sheet | 6,506 modules / 4,960 KB | 360 modules / 1,700 KB | | @knitui/carousel | 6,541 modules / 5,068 KB | 398 modules / 1,808 KB | | @knitui/media | 6,530 modules / 5,056 KB | 384 modules / 1,796 KB | That is −94.7% modules and −3,261 KB of source off the components graph, and it lands on Metro cold start, on every clear-cache rebuild, and on the JS bundle itself for consumers whose bundler cannot shake a src-shipped barrel. `@knitui/icons` gains two subpath exports (the minor): - @knitui/icons/contextIconProvider, useIconContext and their types - @knitui/icons/typesIconProps, IconNode, IconComponent, IconType Per-glyph deep imports (@knitui/icons/IconCheck) already resolved through the existing ./Icon* wildcard; the provider was the one thing that had no subpath and therefore forced the barrel. Nothing was removed — the root barrel still exports everything it did. Internal import rewrites (patch, no API change) across the shipped source of @knitui/components (ControlIconProvider, Accordion, Checkbox/CheckIcon, Chip, CloseButton, Combobox, Stepper), @knitui/carousel (Carousel) and @knitui/media (the audio/video chrome + LiveAudioMeter), each now naming the glyph module it actually uses. An ESLint no-restricted-imports guardrail in eslint.config.base.mjs blocks the bare @knitui/icons / @knitui/emoji specifier in shipped src/** so this cannot regress; stories, tests and the private @knitui/demo galleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: - sideEffects was missing from @knitui/media, @knitui/sheet and @knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it. media and sheet are declared sideEffects: false (audited: their only module-scope statements are pure const initialisers and a displayName assignment — the one registerProcessor() call in media lives inside an AudioWorklet source string, not in the module). plugins lists just its babel-plugin entry, which really does mutate process.env.TAMAGUI_IGNORE_BUNDLE_ERRORS on load. - @knitui/plugins/next-plugin now sets experimental.optimizePackageImports for @knitui/icons, @knitui/emoji, @knitui/components, @knitui/dates and @knitui/map, so every Next consumer inherits it instead of having to know. Next has to build a barrel's full module graph before it can shake it, and transpilePackages means it compiles the kit's source to do so — with this, a bare-barrel glyph import in app code is rewritten to that glyph's own module and the other 6,145 files are never compiled. Any experimental the app already set is merged, not replaced, and its own optimizePackageImports entries are kept.
  • Take the allocations out of the audio sampler and the Skia visualizer's paint The media store's design was already right — per-field channels, auto-tracked selectors, one FFT per painted frame — so this is a pass over the two paths that run at 60 Hz rather than at tick rate: the PCM sample bridge and the visualizer painter. The sampler did full peak/RMS work per frame, before the silence gate, and even when nothing was listening. The backend posts a ~2048-frame window per display frame for as long as sampling is on, and setSamplingEnabled is driven by mount effects that can outlive the visualizer that asked for it — so with no sampleUpdate listener at all, every frame still allocated a channel array (.map) plus a result object and walked every frame twice (peakOf then rmsOf, ≈245k typed-array reads/second on a stereo source), only for emitSample to then drop it at the silence gate. The handler now returns on hasListeners("sampleUpdate") as its first statement (the same gate timeUpdate already had), fuses peak and RMS into ONE pass over the frames (≈123k reads/second, byte-identical results — the per-channel clamps land exactly where peakOf/rmsOf applied them), and refills a reused channel list + envelope instead of allocating two objects per frame. AudioSampleData already documented its buffers as read-synchronously; that now explicitly covers the channel list too. mixChannels takes an optional result object for the same reason. Time labels subscribed to a raw float although `formatTime` floors to seconds. useAudioState(s => s.currentTime) fails Object.is on every tick, so the <Text> re-rendered at tick rate — 4 Hz on video, higher on native audio — and 3 of every 4 renders produced a byte-identical string. The five timecode labels (TimeCurrent and TimeDisplay on <Audio>, <Video> and <AudioPlaylist>) now select Math.floor(s.currentTime). The scrubbers keep the float — they need sub-second thumb positions. `useMediaSelector` allocated a `Proxy` and a sorted key signature per notification AND per render. Every store change per subscribed leaf built a fresh tracking Proxy in runTracked, a [...keys].map(String).sort().join() in register(), then a second Proxy + signature during the re-render and a third signature in the post-commit effect — ≈3 proxies and 3 sorted strings per leaf per tick. Snapshots are immutable, so the tracking proxy is now cached per snapshot identity in a WeakMap (one proxy serves every leaf that reads that snapshot, and it dies with it) and the tracked key set is compared by size + membership against the registered list, so the steady state allocates nothing. This is noise at the current 4 Hz tick and stops being noise the moment a caller drives position at frame rate. The visualizer built 2 Skia host objects per bar, per frame. Skia.XYWHRect + Skia.RRectXY each return a real host object — a JSI HostObject holding a C++ shared_ptr on native, a JsiSkRect wrapping a fresh Float32Array on web — so variant="mirror" at the default count=48 created and discarded 96 of them per painted frame, ≈5,800/second, on the UI thread. Both bindings also accept a plain object and copy out of it synchronously (JsiSkRect::fromValue / JsiSkRRect::fromValue, and their web twins), so the fill builder now reuses ONE mutable rect/rrect pair for the whole frame: 96 host objects per frame → 2 plain objects. The resulting SkRRect comes from the identical SkRRect::MakeRectXY call, and a zero radius takes addRect, which is what Skia's addRRect degenerates to for a radius-less rrect — the drawn path is unchanged, and a new suite records the exact op sequence (addRRect/addRect/ addCircle/moveTo/lineTo/close) with the values each call received to keep it that way. And it rebuilt the whole shape list as objects every frame to hand it to the builder in the next statement. A useDerivedValue published count fresh {kind,x,y,w,h,r} objects plus a fresh array into a SharedValue per frame (≈2,900 objects/second at the default count, plus another array whenever gain/floor/ reverse was set), read once by the two path worklets and thrown away. Each variant now also exists as a writer into a flat Float64Array that the component owns and reuses forever, and geometry + path building are fused into the two path worklets, so the intermediate never exists: the steady-state paint allocates nothing but the SkPath itself. strokeWidth no longer builds a count-length zero array and runs the whole variant to read one number either. The cost is one extra variant pass per frame (pure arithmetic into a buffer) in exchange for a SharedValue write, a mapper, and every allocation on the path. Float64Array rather than Float32Array so the coordinates round-trip exactly. VisualizerVariant, registerVisualizerVariant and the six exported variants are UNCHANGED — the object-shaped variants are now thin decoders over their writers (one implementation of each variant's geometry, so the two forms cannot drift), and a foreign variant is adapted by a shim that encodes its returned shapes, paying exactly the allocation it paid before. `<AudioMesh>` repainted a full-canvas fragment shader 60 times a second even with its drift frozen. It drove its uniforms from Skia's useClock(), whose frame callback runs for as long as the component is mounted; every tick dirtied the uniforms mapper, so prefers-reduced-motion (which pins the drift at t = 0) and speed={0} (documented as "freezes the orbit") both kept rebuilding uniforms and repainting pixel-identical output forever. The clock is now local and stopped whenever the drift is frozen — no writes, so no mapper run, so no repaint — and a frozen mesh repaints only when the AUDIO moves it. Its uniforms also go into buffers the component owns (buildMeshUniformsInto): 3 arrays + a [w, h] tuple + the record per frame → just the record, which has to stay fresh or reanimated skips the SharedValue write and <Shader> never sees the frame. buildMeshUniforms keeps its exact allocating shape for other callers, over the same single implementation of the math. Also, in the same spirit: - useVisualizerSource built a ${count}|${fftScale}|…|${bins} memo key on EVERY pushed FFT row (up to display rate) to prove nothing had changed; the mapper cache now lives in the reducer's closure, keyed by the settings, so only the pushed length is compared. - TypedEmitter.emit copied its listener Set into a fresh array on every emit — including per audio frame. The single-listener case (a visualizer on sampleUpdate, a scrubber on timeUpdate) now dispatches without the copy, with identical semantics: a listener it removes was already dispatched, and one it ADDS is still not delivered to in the same emit (which plain live iteration WOULD do — a Set grown during iteration yields the new entries; there is a test for exactly that). - The mic-stream reductions (frameFromTimeDomain, native useAudioStream) write the envelope straight into the frame/level object through a reused one-slot channel list: 3 allocations per captured buffer → 1 (the level object stays fresh — it is handed to onLevel and to React state). No public API changes in either package. Deferred: moving useLevelTransition's smoother from the JS thread onto the UI thread, and/or publishing its eased row as a ping-ponged typed array (today: 60 boxed 48-element arrays per second, each converted element-by-element into a reanimated serializable on native — a shared ArrayBuffer would skip both). The allocation counts are certain, but it touches the documented Reanimated-4 SharedValue rules, changes the public SharedValue<number[]> level type, and trades a fresh-array-per-frame guarantee for double buffering, so the win wants on-device measurement first.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/components@0.6.0 - @knitui/icons@0.6.0 - @knitui/core@0.6.0 - @knitui/hooks@0.6.0

@knitui/sheet

  • Updated dependencies[5b5a3e0] - @knitui/components@0.4.0 - @knitui/core@0.4.0 - @knitui/hooks@0.4.0 - @knitui/icons@0.4.0

0.3.1

@knitui/carousel

  • Updated dependencies[89f8c36] - @knitui/components@0.3.0 - @knitui/core@0.3.0 - @knitui/hooks@0.3.0 - @knitui/icons@0.3.0

@knitui/map

  • Fix SvgImage icons rendering larger on iOS than on Android/web. react-native-svg's toDataURL bakes the device scale (UIScreen.scale, e.g. 3×) into the rasterized bitmap on iOS but not on Android/web, so registering the icon with scale = pixelRatio left iOS icons UIScreen.scale× too big. The rasterizer now derives the registered density from the bitmap's real pixel width (read from the PNG header), so an icon renders at its logical width/height identically on web, iOS, and Android.

@knitui/media

  • Align published dependency ranges with Expo SDK 57 The SDK pins exact versions for its native modules, and several of our published ranges had drifted from that set. Consumers on SDK 57 were resolving versions the SDK's prebuilt binaries don't expect, which fails at runtime rather than at build time. - @knitui/components: expo-image ~57.0.0~57.0.1 - @knitui/core: @tamagui/* ^2.3.0^2.4.6 - @knitui/media: expo-audio ~57.0.0~57.0.2, expo-video ~57.0.0~57.0.1 - @knitui/plugins: @tamagui/babel-plugin ^2.3.0^2.4.6 @knitui/graphics is a minor rather than a patch because its @shopify/react-native-skia peer is an exact pin and moves 2.6.62.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to 2.6.6 will need to move to 2.6.2 to satisfy the peer.
  • Updated dependencies[4f7830c] - @knitui/components@0.5.0 - @knitui/core@0.5.0 - @knitui/hooks@0.5.0 - @knitui/icons@0.5.0

@knitui/sheet

  • Updated dependencies[89f8c36] - @knitui/components@0.3.0 - @knitui/core@0.3.0 - @knitui/hooks@0.3.0 - @knitui/icons@0.3.0

0.3.0

@knitui/components

  • minor chore(deps): move the supported baseline to Expo SDK 57 and Next.js 16. Upgrades the kit's external toolchain to the latest majors: - Expo SDK 56 → 57react-native 0.85.3 → 0.86.0, react-native-reanimated 4.3.1 → 4.5.0, react-native-worklets 0.8.3 → 0.10.0, react-native-gesture-handler ~2.31 → ~2.32, babel-preset-expo → ^57, and all expo-* packages to their SDK 57 versions. React stays 19.2.3. - Next.js 15 → 16 — the web app opts back into the webpack builder (next build loader yet. Consumer-facing dependency changes: - @knitui/components now depends on expo-image ~57.0.0 (was a stale ~2.4.1), which also resolves the Expo SDK 56 Android startup crash consumers hit from the old pin. - @knitui/media now depends on expo-audio / expo-video ~57.0.0. The two version-pinned pnpm patches (expo-audio, expo-modules-core) were migrated to their SDK 57 releases and still apply. Everything typechecks and builds (28/28 turbo tasks, the Expo app tsc`, and the Next.js 16 production build).
  • @knitui/core@0.3.0
  • @knitui/hooks@0.3.0
  • @knitui/icons@0.3.0

@knitui/hooks

  • @knitui/core@0.3.0

@knitui/dates

  • Updated dependencies[89f8c36] - @knitui/components@0.3.0 - @knitui/core@0.3.0 - @knitui/hooks@0.3.0

@knitui/carousel

  • minor feat(carousel): add SwipeDeck — a Tinder-style, cross-platform (React Native + Web) swipe deck. The top card free-drags in 2-D and commits a like / nope / super-like past a directional threshold (distance or flick velocity), flying off while the next card rises. The visual is a pluggable effect (tinder / stack / fan / swipe, or a custom worklet), driven by a richer DeckCardState (animated stack depth + live drag) — the deck's analogue of the carousel's scalar progress. Includes per-direction stamps (custom renderStamp or built-in stampLabels), an imperative ref (swipe / swipeLeft / swipeRight / swipeUp / getActiveIndex), onSwipe* / onActiveIndexChange / onEmpty callbacks, renderEmpty, a mounted-window stackSize, and the kit's standard styles per-slot map (root / card / stamp). Additive — no changes to the existing <Carousel> / <Pagination> surface.

@knitui/map

  • minor Fix native SvgImage/Images crash and correct high-density icon sizing. - Crash fix. @maplibre/maplibre-react-native resolves an object-form image source via Image.resolveAssetSource, which returns null for a bare string (a data: URI from the SVG rasterizer, or a remote URL) and then throws TypeError: Cannot read property 'uri' of null. The native Images adapter now wraps a string source into { uri } so it resolves correctly. This is what broke SDF/object icons on Android and iOS. - Correct `pixelRatio` sizing. The rasterizer upscales the bitmap by pixelRatio for crispness, but the extra density was never registered, so raster icons drew pixelRatio× too big (previously worked around with iconSize). SvgImage now registers the bitmap's density as scale — passed through to map.addImage({ pixelRatio }) on web and to the native image source { uri, scale } (iOS UIImage.scale / Android bitmap.setDensity) — so an icon renders at its logical width/height regardless of pixelRatio. Behavior change: if you set pixelRatio and compensated with a fractional iconSize (e.g. iconSize: 0.5 for pixelRatio: 2), drop that compensation and use iconSize: 1 (or your intended size). SDF icons at the default pixelRatio of 1 are unaffected.

@knitui/media

  • minor Expose the media store for custom chrome, and harden the audio recorder - Selector hooksuseMediaSelector, shallowEqual and the MediaStore contract are now public from both @knitui/media/audio and @knitui/media/video, alongside per-surface useAudioState, useVideoState, useRecorderState and usePlaylistState (plus AudioContext). Custom chrome can now subscribe to exactly the fields it renders instead of re-rendering on every controller tick. - `setAudioMode` — fix a hard webpack/Next build failure. The web backend does not export requestNotificationPermissionsAsync, and a named import of a missing binding is an "Attempted import error" even behind a runtime guard. The module now takes a namespace import so the lookup defers to runtime. - `AudioRecorder` — a denied mic prompt or a faulted stop() no longer strands the recorder mid-recording or surfaces as an unhandled rejection; both land in the terminal error state and recover on retry. - `shouldCorrectPitch` — seed the initial state from the browser default on web rather than from expo's player.shouldCorrectPitch, which reads false until the first setPlaybackRate and so misreported correction as off. - `useAudioStream` — document that channels is native-only; the web backend always down-mixes to mono and reports 1 regardless of the request.
  • Updated dependencies[5b5a3e0] - @knitui/components@0.4.0 - @knitui/core@0.4.0 - @knitui/hooks@0.4.0 - @knitui/icons@0.4.0

@knitui/graphics

  • Updated dependencies[89f8c36] - @knitui/components@0.3.0 - @knitui/core@0.3.0

@knitui/sheet

  • minor Sheet.Header / Sheet.Footer: fixed slots around the scrollable content. The slot system gains two fixed (non-scrolling) regions that frame a Sheet.ScrollView: - Sheet.Header — pinned below the drag handle and above the content. A good home for a title, tabs, or a segmented control that must stay put while the body scrolls. - Sheet.Footer — pinned below the content. A good home for an action bar (e.g. a "Done" button) that stays visible regardless of scroll position. Both are styled(Box) parts with flexShrink: 0, so a nested Sheet.ScrollView (flex: 1) fills and scrolls the space between them. They are opt-in markers (rendered only when present) and are targetable via the per-slot styles map (styles={{ header, footer }}) alongside the existing root / overlay / handle slots.

@knitui/mediaquery

  • @knitui/core@0.3.0
  • @knitui/hooks@0.3.0

0.2.0

@knitui/core

  • minor Add the theme builder to @knitui/core, so consumers can brand and extend the kit's theme without editing source. New exports: - createTheme(options) — build a fully-configured Tamagui config from brand inputs. Every option is optional and layers onto the kit's stock defaults: brand / neutral / accents palettes (a hex seed with an auto-derived 12-step ramp, a @tamagui/colors name, { light, dark } seeds, or explicit ramps), includeDefaultAccents / includeTamaguiColors, radius / space / size / fontSizes scale presets or per-step overrides, zIndex, raw colors, fonts, breakpoints / media, animations / shorthands / settings, and themeBuilder / themes / tamagui escape hatches. - extendTheme(...optionSets) — deep-merge option sets left-to-right, then build. - mergeThemeOptions(...optionSets) — same merge, returns the options (no build). - defineTheme(options) — identity helper that preserves literal types. - themePresets — curated starting points (minimal / vibrant / professional). - Palette utilities: resolvePalette, rampFromHex, isColorName, isHex, TAMAGUI_COLOR_NAMES, the scale presets, and validateThemeOptions. Options are strictly validated (unknown option / scale step / preset, malformed hex, unknown color name, wrong-length ramp, reserved accent name, undefined defaultFont) with actionable "did you mean …?" messages. <Provider> now accepts an optional config prop (falls back to the built-in config) so builder output can be applied. The existing stock config is unchanged.
  • Add @knitui/mediaquery: cross-platform, SSR-safe media queries and responsive breakpoints for web (Next.js) and React Native (Expo). Ships useMediaQuery (matchMedia string or structured descriptor), useBreakpoint / useBreakpointValue over the shared @knitui/core breakpoint scale, an optional MediaQueryProvider with User-Agent device seeding for SSR, and a pure query engine (parseMediaQuery / matchesQuery / queryToString). @knitui/core now re-exports its breakpoints scale.

@knitui/components

  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0 - @knitui/hooks@0.2.0 - @knitui/icons@0.2.0

@knitui/hooks

  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0

@knitui/emoji

  • minor Add @knitui/emoji: a cross-platform (React + React Native) emoji kit generated from Google Noto Emoji SVG data. Each emoji is parsed once at generate time into a render-ready node tree (no runtime XML parsing on native, DOM <svg> on web), with per-emoji id namespacing and .js+.d.ts output so the ~3.8k modules stay tree-shakeable and cheap to typecheck. Exposes named Emoji* components, per-emoji subpath imports, a dynamic name-based Emoji, and an EmojiProvider for ambient size.

@knitui/dates

  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0 - @knitui/components@0.2.0 - @knitui/hooks@0.2.0

@knitui/carousel

  • minor feat(carousel): support loop in scrollMode="native" Native scroll mode now honours loop instead of forcing it off (and no longer warns when loop is requested). Looping is realised by cloning the data ring LOOP_COPIES times in the scroll content and silently recentring the scroll position into the middle copy on settle — the jump is exactly one ring of pixel-identical clones, so it's invisible, and mod-invariant to the engine's progress/index math. Programmatic next/prev/scrollTo travel to the nearest ring copy (shortest visual path). Works on web and native, horizontal and vertical; loop={false} keeps the finite start-aligned behaviour.

@knitui/map

  • minor SvgImage: reliable cross-platform SVG marker icons, unified on react-native-svg. SVG resources are now rasterized to a bitmap via react-native-svg on both web and native (identical output on each platform), then drawn on the GPU as MapLibre SymbolLayer icons — one texture backs thousands of markers with no per-marker DOM or native view. This fixes native, where MapLibre can't decode SVG icons and the previous capture (an offscreen surface nested inside the native MapView) often never painted on the New Architecture, so markers never appeared. The rasterization surface now mounts in a dedicated RasterizerHost that sits outside the map view (a sibling, driven by a shared, ref-counted, content-keyed store), so it paints reliably and identical icons are rasterized only once. New/changed API: - SvgImage now also accepts a uri — an .svg/data:image/svg+xml URL is fetched and rasterized; any other URL (.png, …) is registered directly. Inline svg and pre-rasterized source still work, so this is backwards compatible. - New SvgImages component registers several icons at once — handy with a data-driven iconImage expression for many marker types in a single GPU layer. - New exports: useRasterizedSvg, resolveSvgSize, useSvgMarkup, isSvgMarkup, isSvgUri, resolvePassthrough, and the SvgImageEntry/SvgImagesProps types. Note: because web now rasterizes through react-native-svg, consuming the map on web requires the standard react-nativereact-native-web alias (already present in every Expo / RN-web app, and in this package's Storybook).

@knitui/media

  • minor chore(deps): move the supported baseline to Expo SDK 57 and Next.js 16. Upgrades the kit's external toolchain to the latest majors: - Expo SDK 56 → 57react-native 0.85.3 → 0.86.0, react-native-reanimated 4.3.1 → 4.5.0, react-native-worklets 0.8.3 → 0.10.0, react-native-gesture-handler ~2.31 → ~2.32, babel-preset-expo → ^57, and all expo-* packages to their SDK 57 versions. React stays 19.2.3. - Next.js 15 → 16 — the web app opts back into the webpack builder (next build loader yet. Consumer-facing dependency changes: - @knitui/components now depends on expo-image ~57.0.0 (was a stale ~2.4.1), which also resolves the Expo SDK 56 Android startup crash consumers hit from the old pin. - @knitui/media now depends on expo-audio / expo-video ~57.0.0. The two version-pinned pnpm patches (expo-audio, expo-modules-core) were migrated to their SDK 57 releases and still apply. Everything typechecks and builds (28/28 turbo tasks, the Expo app tsc`, and the Next.js 16 production build).
  • Updated dependencies[89f8c36] - @knitui/components@0.3.0 - @knitui/core@0.3.0 - @knitui/hooks@0.3.0 - @knitui/icons@0.3.0

@knitui/graphics

  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0 - @knitui/components@0.2.0

@knitui/sheet

  • minor Sheet.ScrollView: scroll↔drag handoff on React Native (nested scroll fix). A Sheet.ScrollView nested in the panel now cooperates with the sheet's drag gesture instead of fighting it. Previously the sheet's pan claimed every vertical drag, so the inner list wouldn't scroll on native. The two are now coordinated so a single vertical drag either moves the sheet or scrolls the list — never both: - At the top snap the list scrolls normally. Drag it back to the top and keep pulling down and the sheet takes over (collapses toward the next snap, then dismisses), with the panel's motion anchored to the finger so there's no jump. - From a partially-open snap the drag moves the sheet, and the list stays pinned to the top so it can't scroll mid-collapse. - A downward fling on the list no longer collapses the sheet. Implementation notes: - On native, Sheet.ScrollView is a purpose-built Animated.ScrollView bound to the sheet's Gesture.Native (so the two recognise simultaneously) and reports its scroll offset back to the pan; it uses the platform scroll indicators. On web it stays a ScrollArea wrapper (the browser owns nested scrolling), so behaviour there is unchanged. - The handoff decision logic lives in a pure, unit-tested engine module (engine/handoff.ts); no public API changed.

@knitui/mediaquery

  • minor Add @knitui/mediaquery: cross-platform, SSR-safe media queries and responsive breakpoints for web (Next.js) and React Native (Expo). Ships useMediaQuery (matchMedia string or structured descriptor), useBreakpoint / useBreakpointValue over the shared @knitui/core breakpoint scale, an optional MediaQueryProvider with User-Agent device seeding for SSR, and a pure query engine (parseMediaQuery / matchesQuery / queryToString). @knitui/core now re-exports its breakpoints scale.
  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0 - @knitui/hooks@0.2.0

0.1.11

@knitui/plugins

  • Updated dependencies[8de27f7]
  • Updated dependencies[85594a1] - @knitui/core@0.8.0

0.1.10

@knitui/plugins

  • Dependency refresh, all within the current majors and validated against Expo SDK 57 (expo install --check reports the workspace aligned): - react-native 0.86.0 → 0.86.2 (and @react-native/metro-config to match; both stay pinned as singletons in the root pnpm.overrides) - react-native-reanimated 4.5.0 → 4.5.1 and react-native-worklets 0.10.0 → 0.10.1 — the versions Expo SDK 57 expects - expo 57.0.7 → 57.0.9 and the SDK-managed modules along with it (expo-router, expo-constants, expo-linking, expo-system-ui, expo-video, @expo/metro-runtime, expo-build-properties) - babel-preset-expo 57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5, @vitejs/plugin-react 6.0.3 → 6.0.4, next 16.2.10 → 16.2.12, @react-navigation/* patch bumps The vendored expo-modules-core patch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly. expo-audio deliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
  • Updated dependencies[59d065b] - @knitui/core@0.7.0

0.1.9

@knitui/plugins

  • Updated dependencies[ffc254e]
  • Updated dependencies[caa2d7e] - @knitui/core@0.6.1

0.1.8

@knitui/plugins

  • Stop the icon barrel from being pulled into every component @knitui/components dragged all ~6.1k icon modules into any app that imported a single component. The kit SRC-SHIPS, so Metro compiles what it resolves and does NOT tree-shake: one line in internal/ControlIconProvider.tsximport { IconProvider } from "@knitui/icons" — resolved the root barrel (packages/icons/src/index.ts, 378 KB / 6,153 export lines), and because that module is reachable from Button, Chip, Accordion and friends, every glyph became part of the graph. Walking the import graph from each package entry, honouring exports conditions and .web/.native order: | entry | before | after | | -------------------- | ------------------------ | ---------------------- | | @knitui/components | 6,490 modules / 4,896 KB | 344 modules / 1,635 KB | | @knitui/sheet | 6,506 modules / 4,960 KB | 360 modules / 1,700 KB | | @knitui/carousel | 6,541 modules / 5,068 KB | 398 modules / 1,808 KB | | @knitui/media | 6,530 modules / 5,056 KB | 384 modules / 1,796 KB | That is −94.7% modules and −3,261 KB of source off the components graph, and it lands on Metro cold start, on every clear-cache rebuild, and on the JS bundle itself for consumers whose bundler cannot shake a src-shipped barrel. `@knitui/icons` gains two subpath exports (the minor): - @knitui/icons/contextIconProvider, useIconContext and their types - @knitui/icons/typesIconProps, IconNode, IconComponent, IconType Per-glyph deep imports (@knitui/icons/IconCheck) already resolved through the existing ./Icon* wildcard; the provider was the one thing that had no subpath and therefore forced the barrel. Nothing was removed — the root barrel still exports everything it did. Internal import rewrites (patch, no API change) across the shipped source of @knitui/components (ControlIconProvider, Accordion, Checkbox/CheckIcon, Chip, CloseButton, Combobox, Stepper), @knitui/carousel (Carousel) and @knitui/media (the audio/video chrome + LiveAudioMeter), each now naming the glyph module it actually uses. An ESLint no-restricted-imports guardrail in eslint.config.base.mjs blocks the bare @knitui/icons / @knitui/emoji specifier in shipped src/** so this cannot regress; stories, tests and the private @knitui/demo galleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: - sideEffects was missing from @knitui/media, @knitui/sheet and @knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it. media and sheet are declared sideEffects: false (audited: their only module-scope statements are pure const initialisers and a displayName assignment — the one registerProcessor() call in media lives inside an AudioWorklet source string, not in the module). plugins lists just its babel-plugin entry, which really does mutate process.env.TAMAGUI_IGNORE_BUNDLE_ERRORS on load. - @knitui/plugins/next-plugin now sets experimental.optimizePackageImports for @knitui/icons, @knitui/emoji, @knitui/components, @knitui/dates and @knitui/map, so every Next consumer inherits it instead of having to know. Next has to build a barrel's full module graph before it can shake it, and transpilePackages means it compiles the kit's source to do so — with this, a bare-barrel glyph import in app code is rewritten to that glyph's own module and the other 6,145 files are never compiled. Any experimental the app already set is merged, not replaced, and its own optimizePackageImports entries are kept.
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5f44d20]
  • Updated dependencies[5c6d758] - @knitui/core@0.6.0

0.1.7

@knitui/plugins

  • Align published dependency ranges with Expo SDK 57 The SDK pins exact versions for its native modules, and several of our published ranges had drifted from that set. Consumers on SDK 57 were resolving versions the SDK's prebuilt binaries don't expect, which fails at runtime rather than at build time. - @knitui/components: expo-image ~57.0.0~57.0.1 - @knitui/core: @tamagui/* ^2.3.0^2.4.6 - @knitui/media: expo-audio ~57.0.0~57.0.2, expo-video ~57.0.0~57.0.1 - @knitui/plugins: @tamagui/babel-plugin ^2.3.0^2.4.6 @knitui/graphics is a minor rather than a patch because its @shopify/react-native-skia peer is an exact pin and moves 2.6.62.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to 2.6.6 will need to move to 2.6.2 to satisfy the peer.
  • Updated dependencies[4f7830c] - @knitui/core@0.5.0

0.1.6

@knitui/plugins

  • @knitui/core@0.4.0

0.1.5

@knitui/plugins

  • fix(next): alias bare global to globalThis in the client bundle react-native-reanimated's web build touches the Node-ism global at module-eval time, so importing it in the browser threw ReferenceError: DefinePlugin({ global: "globalThis" }) for the client bundle only (!isServer`), leaving the real Node global untouched on the server.

0.1.4

@knitui/plugins

  • @knitui/core@0.3.0

0.1.3

@knitui/plugins

  • Fix @knitui/plugins/next (and every other subpath) reporting "Could not find a declaration file for module" in consumers' tsc. The typescript bob target built with project: tsconfig.build.json, whose tsconfig.build.json set no rootDir. tsc therefore inferred the package root as the root and emitted the .d.ts files one level too deep — at lib/typescript/module/src/next.d.ts — while the exports[...].types paths (correctly) pointed at lib/typescript/module/next.d.ts. Runtime JS resolved fine, but a consumer's type checker (bundler resolution) couldn't find the declarations. Setting rootDir: "src" in tsconfig.build.json flattens the output to exactly where exports already points. No API or runtime change.

0.1.2

@knitui/carousel

  • Carousel: add scrollMode="native" — an opt-in "normal scroll" mode backed by a real platform scroll container (an Animated.ScrollView on native, an overflow-scroll surface with CSS scroll-snap on web). Scrolling, momentum and rubber-band overscroll are the OS's own and nothing runs per frame on the JS thread. The live scroll position is mirrored into the same scroll-offset shared value the transform engine uses, so pagination, progress/onProgressChange, the active index, controlled index and the imperative ref (next/prev/scrollTo) keep working. snapEnabled / pagingEnabled / overscrollEnabled, vertical and itemSize/itemWidth/itemHeight are honoured. Native mode mounts every slide (no windowed virtualization), forces loop off, and ignores the transition mode/customAnimation (slides lay out in normal flow). Default stays scrollMode="transform" — no behaviour change for existing usage.
  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0 - @knitui/components@0.2.0 - @knitui/hooks@0.2.0 - @knitui/icons@0.2.0

@knitui/media

  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0 - @knitui/components@0.2.0 - @knitui/hooks@0.2.0 - @knitui/icons@0.2.0

@knitui/sheet

  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0 - @knitui/components@0.2.0 - @knitui/hooks@0.2.0 - @knitui/icons@0.2.0

@knitui/plugins

  • Updated dependencies[c346356]
  • Updated dependencies[737463e] - @knitui/core@0.2.0

0.1.1

@knitui/components

  • UnstyledButton: reset the semantic <button>'s user-agent text-align: center on web so text content is left-aligned, matching native (React Native starts pressable text at the inline edge). The same tree no longer diverges across platforms. The reset is web-only and applied ahead of the caller's style, so anyone who wants centred content still overrides it explicitly. Internally the button-host wiring now reuses the shared webButton() helper (correctly a no-op on native) instead of hardcoding render="button". - @knitui/core@0.1.1 - @knitui/hooks@0.1.1 - @knitui/icons@0.1.1

@knitui/hooks

  • @knitui/core@0.1.1

@knitui/dates

  • Updated dependencies[407bef6] - @knitui/components@0.1.1 - @knitui/core@0.1.1 - @knitui/hooks@0.1.1

@knitui/carousel

  • Updated dependencies[407bef6] - @knitui/components@0.1.1 - @knitui/core@0.1.1 - @knitui/hooks@0.1.1 - @knitui/icons@0.1.1

@knitui/map

  • Map: fix SVG marker pins (SvgImage) not appearing on Android/iOS. MapLibre native can't decode SVG, so SvgImage rasterizes the SVG to a PNG through react-native-svg's Svg.toDataURL. The capture fired once, synchronously, in the offscreen view's onLayout — but on the New Architecture the native Svg view isn't attached/painted on that first frame, so the call silently no-op'd (ref not ready) or returned empty bytes with no retry, and no icon was ever registered. Capture now runs in a requestAnimationFrame retry loop that waits until the ref is attached and toDataURL returns real bytes before registering the image, and the offscreen host is positioned off-screen instead of opacity: 0 (an alpha-0 source view can snapshot blank on Android). Web is unchanged. Also fix a web layer-update gap: a paint/layout key present in a layer's initial config (e.g. a SymbolLayer iconRotate) and then dropped now resets to its spec default instead of lingering, because the dropped-key trackers are seeded from the config applied at add time.

@knitui/media

  • Updated dependencies[407bef6] - @knitui/components@0.1.1 - @knitui/core@0.1.1 - @knitui/hooks@0.1.1 - @knitui/icons@0.1.1

@knitui/graphics

  • Updated dependencies[407bef6] - @knitui/components@0.1.1 - @knitui/core@0.1.1

@knitui/sheet

  • Updated dependencies[407bef6] - @knitui/components@0.1.1 - @knitui/core@0.1.1 - @knitui/hooks@0.1.1 - @knitui/icons@0.1.1

@knitui/plugins

  • @knitui/core@0.1.1