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
ProvideracceptsbackgroundColorfor 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, andsetConfigjoinsgetConfigin the@knitui/coresurface. Tamagui keeps one config per process andcreateTamaguiregisters its result as a side effect of being called, but<TamaguiProvider config={…}>does not register what it is passed — it only readsgetCSS()andanimations. So an app that built its own config withcreateThemewas in a two-writer race with the kit's built-in config (theconfigprop'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'sinlineRequiresdefers that import to its first use, so any code that merely touched the built-in config (a diagnostic comparing it againstgetConfig(), 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$brandColorresolved 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 latecreateTamaguino 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately stays on 57.0.2, where our native sampling patch is pinned. Peer requirements for consumers are unchanged.
@knitui/components
- minor
VirtualListcan page backwards and hold the reading position while it doesVirtualListshipped withonEndReachedonly, 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, default0.5viewports) — the mirror ofonEndReached, 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. LikeonEndReachedon 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.resizeLayoutStatepreserves 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 akeyExtractor, 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). UndermaintainVisibleContentPositiona non-animatedscrollToEndnow 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 needkeyExtractor: 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/scrollToTopdrop any held anchor — an explicit destination outranks whatever the list was holding on to. - minor VirtualList: `keepMounted`, keyed identity, and cheaper re-renders.
VirtualListgained akeepMountedprop — 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 indatais 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 akeyExtractornow 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 akeyExtractorthe 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, andprependLayoutStateis gone.maintainVisibleContentPositionstill 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 inlineItemSeparatorComponent/styles.itemliteral re-renders only the cheap positioned wrapper and no longer re-runs yourrenderItem.styles.itemis also identity- stabilised internally, so writing it inline is now free. Fixes a crash whendatashrank 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 handedrenderItemanundefineditem. - Dependency refresh, all within the current majors and validated against Expo SDK 57 (
expo install --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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.tscarries thedeclare module "@tamagui/core"augmentation that teaches Tamagui this kit's token and shorthand vocabulary (maw, the$tokenunions). Nothing else on the barrel imported that module, so once consumers resolve the package by its builtindex.d.tsthe 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
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes not re-emit, so its built barrel cannot resolve them.
@knitui/components
- Fix
NaNcolour channels for an out-of-range hue.hsvaToRgbaObjectpicks one of six channel tuples byhue / 60and never normalised the hue first, so a negative one indexed off the front of those tuples and producedNaNfor red, green and blue.ColorPickerreaches this on every hue change — it passes the hue straight through as a plain number — which madeconvertHsvaToserialisergb(NaN, NaN, NaN). Hues now wrap by Euclidean remainder, so-60resolves as300and420as60. Colour _strings_ were never affected: a negative hue fails validation and resolves to black, as before. - Point every
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes 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
useListStatecorrupting the list on an out-of-range index.reorderdestructured the spliced-out item, so afrombeyond the end inserted a literalundefinedinto the list.swapwroteundefinedover a real row when either index was out of range, andsetItemPropspread a missing row into a bare{ [prop]: value }object masquerading as aT. 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
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes not re-emit, so its built barrel cannot resolve them.
@knitui/dates
- Point every
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes 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
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes 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 removedconfig/themes.tscalledcreateThemes(...)without acomponentThemesargument, so it inherited Tamagui'sdefaultComponentThemes— a 19-entry map that is itself@deprecatedupstream ("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 | resolvedVariables | 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: falseis set both in the stockconfig/themes.tsand increateTheme(), so a consumer-built config has the same theme set as the shipped one (24 names there — the stock 22 plus thebrandalias 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">, athemesoverride keyeddark_gray_Card, or acreateTheme({ 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'sstyled()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 acrosslight,dark,light_blueanddark_red(100 probes, 0 differences): | component | template | change | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | |ListItem| surface1 | none — the frame paints nothing, andsurface*leaves$color1…$color12and$coloralone | |Progress| surface1 | none — already explicit$colorN| |Button| surface3 |variant="default"border pinned$borderColor→$color7(the only variant reading$borderColor) | |Card| surface1 | frame +Card.Sectionborder pinned to$color2/$color5| |Checkbox| surface2 | unchecked box pinned to$color3/$color6| |SliderTrack| inverse | ramp steps written pre-mirrored ($color3→$color10; childSliderBar$color9→$color4) | |SliderThumb| inverse | pre-mirrored ($color1→$color12,$color9→$color4, hover$color10→$color3; childSliderLabelBubbletoo) | |SwitchThumb| inverse | childSwitchThumbIndicatorpre-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 fourinversecollisions were **reversing the entire$color1…$color12ramp** under those subtrees, not offsetting one step. SoTooltipwas authored$color9fill +$color1label (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/$color1etc.) is a deliberate follow-up. 2. **Descendants** of those nine frames no longer inherit the offset theme. A component placed inside aCardused to resolve$backgroundone 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_MEDIAalso 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 - **matchesQueryno longer re-parses on every call.** The nativeuseMediaQueryevaluated 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 (parseMediaQueryhands 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 anisReduceMotionEnabled()promise — 20 call sites meant 60 live listeners. Worse, every handler didsetEnv(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 throughuseSyncExternalStorewhose snapshot is the resolved **boolean**, so React bails per component unless that component's answer actually changed. - **useBreakpointrenders on band crossings, not resize pixels.** It was backed byuseViewportSize, whose untreatedresizelistener 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 — thoughresolveBreakpointyields only 8 values across 7 thresholds.mediaquerynow owns a viewport store that wakes subscribers only when the band changes, and the hook's snapshot is theBreakpointKeystring. (@knitui/hooks'useViewportSizeis untouched — it has other consumers that want the pixel width.) - **useSystemColorScheme** builds itsMediaQueryListonce at module scope instead of constructing a live, document-registered query object insidegetSnapshot(which React calls per render and per store-change check, twice over in StrictMode) and again insubscribe. No public API change in@knitui/mediaquery;useBreakpointkeeps its SSR contract (the server snapshot and first hydrating render both resolve theMediaQueryProvider` seed). - minor Memoize
.styleable()output so a parent render stops re-rendering the whole kit Tamagui memoizes the componentstyled()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 isstyled(...).styleable(...), and the.styleable()HOC **is** the exported component — 155 sites across@knitui/components(139) and@knitui/dates(16). The kit passed neithermemonor 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 parentsetStatewith **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 inlineonPressper 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/corenow exports its ownstyled** (src/styled.ts), which is Tamagui's factory plusstaticConfig.memo = trueon the frame it returns.styleable()reads the flag offextendStyledConfig(), which spreads the frame's ownstaticConfigfirst and the per-calloptions.staticConfigsecond — 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 amemokey intostaticConfig(verified), so this is a post-hoc assignment rather than an option passed through, andsrc/essentials.tsno longer re-exports the raw Tamaguistyled— there is onestyledon the@knitui/coresurface.memois 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/components120 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
lineHeightRatiosclimbed 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.$xxlheadings 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 (bodylineHeightsplusheadings.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) / 2centering 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 themdratio regardless of the number, sofontSize={28}andfontSize="$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.TableOfContentshardcoded its ownvalue * 1.4for numeric sizes and now goes through the same ladder. This shifts rendered text metrics across the kit — anything measuring or pinning line boxes (notablyTextarearow heights, which derive fromrows × lineHeight) will lay out slightly differently. Consumers on^0.5.xopt 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 theif (!gradient) returnbail, soButton,ActionIcon,Badge,Avatar,CloseButton,ThemeIcon,PillandAlerteach paid a fulluseThemeWithState—useId+useRef+useReducer+ a dependency-lessuseEffectthat fires after every render + snapshot bookkeeping — unless the caller actually passedvariant="gradient". On web the theme was never needed at all: a$tokenresolves to its CSS custom property by pure string transform, so there is now a theme-freetheme-color-web.tsand 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 throughuseTheme()once per control _section_, so aButtonwith both a left and a right section instantiated two on top of its own frame. On native that read trips Tamagui'strack(), 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 tovar(--…)with no hook; native keeps the theme read, behind a newuse-icon-colorsplit. `renderTextChild` cloned every child to answer a question it already knew.React.Children.toArrayflattens _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 whattypeof childrenalready told it. Now: string/number, single-element and nullish children take fast paths beforetoArray, and the array scan is one loop instead ofsome+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 memoizedTreeNode,TreeSelect, and 6× each inTagsInput/MultiSelect/ColorPicker), it built an object plus two closures on every call even when nostylesprop was passed. The empty case now returns one shared frozen accessor, andmergeonly builds a new object when both sides are present — which also means the props spread onto a part keep a stable identity, so downstreamReact.memoand Tamagui prop comparisons can bail out. (Verified safe: no call site mutates an accessor result.) The dev-only known-slotSetis cached in aWeakMapkeyed by the*_SLOT_KEYSconstant instead of being rebuilt per render. `Button`'s slot collection re-walked its children every render. An inline options literal, avisitclosure, 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'schildrenidentity changed on every render even for<Button>Save</Button>. Marker matching is now aMapbuilt once indefineSlots, 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 onslotProps_identity_, but the documented usage is an inlinestyles={{ 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()andButton's native id props were rebuilt per render even thoughisWebis fixed at build time;UnstyledButtonalso built a fresh style _array_ every render, which defeated Tamagui's style caching.Group'sgrowstyle and theTooltip/HoverCardtrigger-clone handler bundles are memoized, so cloning a child no longer hands it a new style object and break itsReact.memo— which is what was re-rendering memoized icons inside a<Group grow>. `Paper` and `Typography` dropped a component layer each. Both were.styleablewrappers that added zero or one prop, so they are now plainstyled()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 removedconfig/themes.tscalledcreateThemes(...)without acomponentThemesargument, so it inherited Tamagui'sdefaultComponentThemes— a 19-entry map that is itself@deprecatedupstream ("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 | resolvedVariables | 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: falseis set both in the stockconfig/themes.tsand increateTheme(), so a consumer-built config has the same theme set as the shipped one (24 names there — the stock 22 plus thebrandalias 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">, athemesoverride keyeddark_gray_Card, or acreateTheme({ 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'sstyled()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 acrosslight,dark,light_blueanddark_red(100 probes, 0 differences): | component | template | change | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | |ListItem| surface1 | none — the frame paints nothing, andsurface*leaves$color1…$color12and$coloralone | |Progress| surface1 | none — already explicit$colorN| |Button| surface3 |variant="default"border pinned$borderColor→$color7(the only variant reading$borderColor) | |Card| surface1 | frame +Card.Sectionborder pinned to$color2/$color5| |Checkbox| surface2 | unchecked box pinned to$color3/$color6| |SliderTrack| inverse | ramp steps written pre-mirrored ($color3→$color10; childSliderBar$color9→$color4) | |SliderThumb| inverse | pre-mirrored ($color1→$color12,$color9→$color4, hover$color10→$color3; childSliderLabelBubbletoo) | |SwitchThumb| inverse | childSwitchThumbIndicatorpre-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 fourinversecollisions were **reversing the entire$color1…$color12ramp** under those subtrees, not offsetting one step. SoTooltipwas authored$color9fill +$color1label (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/$color1etc.) is a deliberate follow-up. 2. **Descendants** of those nine frames no longer inherit the offset theme. A component placed inside aCardused to resolve$backgroundone 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_MEDIAalso 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 - **matchesQueryno longer re-parses on every call.** The nativeuseMediaQueryevaluated 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 (parseMediaQueryhands 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 anisReduceMotionEnabled()promise — 20 call sites meant 60 live listeners. Worse, every handler didsetEnv(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 throughuseSyncExternalStorewhose snapshot is the resolved **boolean**, so React bails per component unless that component's answer actually changed. - **useBreakpointrenders on band crossings, not resize pixels.** It was backed byuseViewportSize, whose untreatedresizelistener 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 — thoughresolveBreakpointyields only 8 values across 7 thresholds.mediaquerynow owns a viewport store that wakes subscribers only when the band changes, and the hook's snapshot is theBreakpointKeystring. (@knitui/hooks'useViewportSizeis untouched — it has other consumers that want the pixel width.) - **useSystemColorScheme** builds itsMediaQueryListonce at module scope instead of constructing a live, document-registered query object insidegetSnapshot(which React calls per render and per store-change check, twice over in StrictMode) and again insubscribe. No public API change in@knitui/mediaquery;useBreakpointkeeps its SSR contract (the server snapshot and first hydrating render both resolve theMediaQueryProvider` seed). - minor Retune the line-height ladder so leading tracks font size consistently
lineHeightRatiosclimbed 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.$xxlheadings 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 (bodylineHeightsplusheadings.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) / 2centering 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 themdratio regardless of the number, sofontSize={28}andfontSize="$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.TableOfContentshardcoded its ownvalue * 1.4for numeric sizes and now goes through the same ladder. This shifts rendered text metrics across the kit — anything measuring or pinning line boxes (notablyTextarearow heights, which derive fromrows × lineHeight) will lay out slightly differently. Consumers on^0.5.xopt 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
__triggerPropsin its contextuseMemodep 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.__triggerPropsnow 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 isuseCallbackRef-stable (fixed identity, latest closure) and each dep array is now complete with theeslint-disablegone — previously a hitting memo would have served a staleonSearchChangeor a stale__triggerPropscallback.handleSubmitmatters twice over: it lands on<Combobox onOptionSubmit>, a dep of Combobox's own context memo, so churn there reached everyCombobox.Optionregardless. `MultiSelect` / `TagsInput` pills — the pills were mapped un-memoized, each one subscribing to the context that also carriessearch, so typing one character re-rendered every chip (aPill+CloseButton+ icon SVG each), and each got a freshonRemoveclosure becauseremoveValue/removeTagdepended 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 areReact.memo'd, with a stable remove callback, so a keystroke re-renders the field and skips the pills. `Tree` —isNodeCheckedandisNodeIndeterminateeach ran a full recursive traversal that allocated a status object per node and didcheckedState.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.useTreenow builds oneMap<value, status>per(data, checkedState)in a single pass (with aSetfor membership) and both helpers are O(1) lookups. The exported pure helpers keep their signatures and semantics. `Table` —Table.Tbodycloned every row to inject an index andTable.Trcloned 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 usedChildren.map's index, which countsnull/falseslots, 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 memoizedRow, 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 thatextraData— documented as the re-render trigger forrenderItem— was never passed to the row and only "worked" because the memo was broken; it is now a real row prop. Separately,handleScrollchanged identity every render (throughheaderH/footerHstate and inline range/end-reached callbacks) and lands onScrollArea'sonScrollPositionChange, which is a dep of the nativeuseAnimatedScrollHandler— so the Reanimated worklet scroll handler was rebuilt on every windowing render, i.e. mid-fling. The scroll callbacks are nowuseCallbackRef-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.positionis now derived during render from the parsed colour that was already the source of truth. The[]-depshandleChangeworkaround is gone with it: the stale-setter hazard it papered over is fixed inuseUncontrolled, and an honest dep list keeps its identity stable — which matters, because it is a dep of theColorPickerContextmemo. `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:Slidermemoizes 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;AngleSliderhas 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-moveonChangecontract is unchanged. Smaller ones —Combobox.OptionsDropdownbuilt aSetfor the selected values instead ofvalue.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.Accordionmemoized its per-item context value, which was an inline object literal and so re-rendered everyAccordion.Control/Panelon any item render.SegmentedControlcompares the measured layout before setting state, so anonLayoutre-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/__firstprops are gone (no test or consumer asserted on them) andTreegained an exportedgetCheckedNodesMaphelper. - Stop the icon barrel from being pulled into every component
@knitui/componentsdragged 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 ininternal/ControlIconProvider.tsx—import { 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 fromButton,Chip,Accordionand friends, every glyph became part of the graph. Walking the import graph from each package entry, honouringexportsconditions and.web/.nativeorder: | 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/context—IconProvider,useIconContextand their types -@knitui/icons/types—IconProps,IconNode,IconComponent,IconTypePer-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 ESLintno-restricted-importsguardrail ineslint.config.base.mjsblocks the bare@knitui/icons/@knitui/emojispecifier in shippedsrc/**so this cannot regress; stories, tests and the private@knitui/demogalleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: -sideEffectswas missing from@knitui/media,@knitui/sheetand@knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it.mediaandsheetare declaredsideEffects: false(audited: their only module-scope statements are pure const initialisers and adisplayNameassignment — the oneregisterProcessor()call inmedialives inside an AudioWorklet source string, not in the module).pluginslists just itsbabel-pluginentry, which really does mutateprocess.env.TAMAGUI_IGNORE_BUNDLE_ERRORSon load. -@knitui/plugins/next-pluginnow setsexperimental.optimizePackageImportsfor@knitui/icons,@knitui/emoji,@knitui/components,@knitui/datesand@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, andtranspilePackagesmeans 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. Anyexperimentalthe app already set is merged, not replaced, and its ownoptimizePackageImportsentries 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 theif (!gradient) returnbail, soButton,ActionIcon,Badge,Avatar,CloseButton,ThemeIcon,PillandAlerteach paid a fulluseThemeWithState—useId+useRef+useReducer+ a dependency-lessuseEffectthat fires after every render + snapshot bookkeeping — unless the caller actually passedvariant="gradient". On web the theme was never needed at all: a$tokenresolves to its CSS custom property by pure string transform, so there is now a theme-freetheme-color-web.tsand 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 throughuseTheme()once per control _section_, so aButtonwith both a left and a right section instantiated two on top of its own frame. On native that read trips Tamagui'strack(), 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 tovar(--…)with no hook; native keeps the theme read, behind a newuse-icon-colorsplit. `renderTextChild` cloned every child to answer a question it already knew.React.Children.toArrayflattens _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 whattypeof childrenalready told it. Now: string/number, single-element and nullish children take fast paths beforetoArray, and the array scan is one loop instead ofsome+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 memoizedTreeNode,TreeSelect, and 6× each inTagsInput/MultiSelect/ColorPicker), it built an object plus two closures on every call even when nostylesprop was passed. The empty case now returns one shared frozen accessor, andmergeonly builds a new object when both sides are present — which also means the props spread onto a part keep a stable identity, so downstreamReact.memoand Tamagui prop comparisons can bail out. (Verified safe: no call site mutates an accessor result.) The dev-only known-slotSetis cached in aWeakMapkeyed by the*_SLOT_KEYSconstant instead of being rebuilt per render. `Button`'s slot collection re-walked its children every render. An inline options literal, avisitclosure, 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'schildrenidentity changed on every render even for<Button>Save</Button>. Marker matching is now aMapbuilt once indefineSlots, 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 onslotProps_identity_, but the documented usage is an inlinestyles={{ 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()andButton's native id props were rebuilt per render even thoughisWebis fixed at build time;UnstyledButtonalso built a fresh style _array_ every render, which defeated Tamagui's style caching.Group'sgrowstyle and theTooltip/HoverCardtrigger-clone handler bundles are memoized, so cloning a child no longer hands it a new style object and break itsReact.memo— which is what was re-rendering memoized icons inside a<Group grow>. `Paper` and `Typography` dropped a component layer each. Both were.styleablewrappers that added zero or one prop, so they are now plainstyled()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 onerequestAnimationFrame. The capture-phasescrolllistener fires for every scrollable ancestor in the app, and each trigger ran a fullgetBoundingClientRect→computePosition→setStatecycle: 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 stoppedJSON.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 itsrunOnJShop 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 webResizeObserveris 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 aScrollArea, happened mid-fling on every window advance. Looping animations —Skeleton,Loader,ProgressandIndicatordeclare 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 awithRepeatre-evaluating a worklet every frame on native. ALoaderpaid for four, a staticIndicatorand a non-animateSkeletonfor one each. Loops are now scheduled only when they actually animate. `useReducedMotion` — one shared subscription per app instead of onematchMedia/AccessibilityInfolistener 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 aResizeObserver/onLayoutre-fire from a visibility flip or a sibling's reflow no longer re-rendersCollapse/Spoiler/Marqueecontent. `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.animationConfigis a flat record that every call site writes inline, and it is a dependency ofuseSheetDrag'suseMemo— so a fresh identity rebuilt theGesture.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 internaluseStableRecord(with tests) — the sibling of carousel'smodeConfigstabilisation and map'suseStableStyleValue.withAnimation/gestureConfigare 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 anenabledoption. - 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 onerequestAnimationFrame. The capture-phasescrolllistener fires for every scrollable ancestor in the app, and each trigger ran a fullgetBoundingClientRect→computePosition→setStatecycle: 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 stoppedJSON.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 itsrunOnJShop 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 webResizeObserveris 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 aScrollArea, happened mid-fling on every window advance. Looping animations —Skeleton,Loader,ProgressandIndicatordeclare 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 awithRepeatre-evaluating a worklet every frame on native. ALoaderpaid for four, a staticIndicatorand a non-animateSkeletonfor one each. Loops are now scheduled only when they actually animate. `useReducedMotion` — one shared subscription per app instead of onematchMedia/AccessibilityInfolistener 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 aResizeObserver/onLayoutre-fire from a visibility flip or a sibling's reflow no longer re-rendersCollapse/Spoiler/Marqueecontent. `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.animationConfigis a flat record that every call site writes inline, and it is a dependency ofuseSheetDrag'suseMemo— so a fresh identity rebuilt theGesture.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 internaluseStableRecord(with tests) — the sibling of carousel'smodeConfigstabilisation and map'suseStableStyleValue.withAnimation/gestureConfigare 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 anenabledoption. - Give
useUncontrolleda referentially stable setterhandleUncontrolledChangewas a plain function declaration, so the setter had a new identity on every render — and the controlled branch handed back the caller's rawonChange, which callers almost always pass as an inline arrow. Either way, every consumer got a fresh setter every render. Around 48 files acrosscomponents,datesandsheetcall this hook and pass that setter straight into a child, a context value, or auseMemo/useEffectdependency list, so the churn cascaded. The clearest case wasCombobox: a newsetOpenmadeuse-combobox'suseMemostore a new object, which made theComboboxContextvalue 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 latestonChangeread 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 theonChangefrom its first render —ColorPicker'shandleChangedid exactly that — and now always invokes the current one. Behaviour is otherwise unchanged: the controlled branch still never tracks its own state,defaultValuestill wins overfinalValue, and a missingonChangeis 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-onChangeguarantee. - 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/componentsdragged 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 ininternal/ControlIconProvider.tsx—import { 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 fromButton,Chip,Accordionand friends, every glyph became part of the graph. Walking the import graph from each package entry, honouringexportsconditions and.web/.nativeorder: | 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/context—IconProvider,useIconContextand their types -@knitui/icons/types—IconProps,IconNode,IconComponent,IconTypePer-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 ESLintno-restricted-importsguardrail ineslint.config.base.mjsblocks the bare@knitui/icons/@knitui/emojispecifier in shippedsrc/**so this cannot regress; stories, tests and the private@knitui/demogalleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: -sideEffectswas missing from@knitui/media,@knitui/sheetand@knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it.mediaandsheetare declaredsideEffects: false(audited: their only module-scope statements are pure const initialisers and adisplayNameassignment — the oneregisterProcessor()call inmedialives inside an AudioWorklet source string, not in the module).pluginslists just itsbabel-pluginentry, which really does mutateprocess.env.TAMAGUI_IGNORE_BUNDLE_ERRORSon load. -@knitui/plugins/next-pluginnow setsexperimental.optimizePackageImportsfor@knitui/icons,@knitui/emoji,@knitui/components,@knitui/datesand@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, andtranspilePackagesmeans 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. Anyexperimentalthe app already set is merged, not replaced, and its ownoptimizePackageImportsentries are kept. - Stop publishing the
lib/build output that nothing can resolve Both packages listed"lib"infilesand ranbob build, but every resolver key —main,module,types,react-native,sourceand everyexportscondition — points at./src/.... Nothing in either package, the workspace, or a consumer's resolution can reachlib/: these two src-ship, like the rest of the kit. The build output was shipped to npm as dead weight. Measured withnpm 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 foremoji. Install size only; no bundle, API or resolution change, which is exactly why it was invisible.bob buildstill 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"infilesand ranbob build, but every resolver key —main,module,types,react-native,sourceand everyexportscondition — points at./src/.... Nothing in either package, the workspace, or a consumer's resolution can reachlib/: these two src-ship, like the rest of the kit. The build output was shipped to npm as dead weight. Measured withnpm 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 foremoji. Install size only; no bundle, API or resolution change, which is exactly why it was invisible.bob buildstill 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,useCallbackorReact.memoanywhere outsideuse-dates-context, so every render re-derived the entire month grid from scratch — and a web hover move is a render, because it updateshoveredDateon 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 anew Dateplus an object;formatwithMMMMhits 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 |formatbefore | after | | ---------------------------------------- | -----------: | ------: | --------------: | ------: | |Monthre-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 | | …withnumberOfColumns={2}| 6,442 | 646 | 523 | 134 | Redundant per-cell prop getters.getDateInTabOrderinvokedgetDayProps(date)twice for all 42 dates (once fordisabled, once forselected) andMonth's cell loop invoked it a third time.DatePickerwires that getter touseDatesState'sgetControlProps, which for a range picker runsrangeView.some(isSame)+isDateInRange+isFirstInRange+isLastInRange— ~20 dayjs instances a call. So ~1,400 instances went on deciding which single cell getstabIndex={0}. The getter is now resolved once per date into a per-renderMapshared by both consumers. Same fix inMonthsList/YearsListviagetMonthInTabOrder/getYearInTabOrder. String comparisons instead of dayjs round-trips.isSameMonth,isBeforeMaxDate,isAfterMinDate,isInRangeanduseDatesState'sisSame(date, level)all took values that are ALREADY zero-paddedYYYY-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:isInRangewent 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 forDateobjects 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 forisInRange). Memoized grid derivation.getMonthDays(~70 instances + 35formatcalls) 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'saria-label— move into oneuseMemo; onlygetDayPropsand the rovingtabIndex, 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 patternCombobox'sOptionRowandTagsInput's chips already use) with a comparator that looks one level into plain-object props — necessary becausegetDayProps(date)and thestylesslot accessors return fresh objects holding unchanged booleans. The grid's__-prefixed callbacks are stabilised withuseCallbackRefso 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__getDayRefand rebuilding the level group'sdaysRefsmatrix, on every render. `Day` no longer computes a label that is thrown away.Monthalways passes its ownaria-labelthrough...rest, spread afterDay's default — so theday.locale(…) fallback is now built only when there is no explicit label (which also means nativeaccessibilityLabelannounces the custom label instead of the generic date). The today check skips its work unlesshighlightTodayis set, and reads three calendar fields off the existing instance rather than allocating aDateplus three dayjs clones per cell. **TimePickerdropdown.** A24h-with-seconds dropdown renders 24 + 60 + 60 controls, and every keystroke in the segment inputs updatescontroller.values, rebuilding all 144 plus their range arrays. The ranges are memoized,TimeControlisReact.memo'd, andonSelectis 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 losingactiveand the one gaining it. No public API changes —Day's andMonth's prop contracts are untouched, and the memoized cell is internal.getMonthDays,isSameMonth,isInRangeand friends keep their exact signatures and return values. **MonthsListandYearsListgot the memoized cell too.**Monthhad a memoized day cell; the other two grids did not, so a RANGE month/year picker — which updateshoveredDateon every web hover move — re-rendered all 12/10PickerControlleaves and detached/reattached every ref (re-running__getControlRefand 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 viauseCallbackRefso the compare can actually hold. The one-level-deep memo comparator is now shared by all three grids ininternal/are-cell-props-equal(with tests) rather than living privately inMonth. It stays deliberately shallow: anything it cannot prove equal — a nested object, a fresh function — reads as changed and simply re-renders the cell. **WeekdaysRowrebuilt its 7 labels on every render.**getWeekdayNamescosts ~22 dayjs instances plus 7 locale-table formats, and it ran unmemoized insideMonth— so it was paid on every hover frame, timesnumberOfColumns`. 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
setSamplingEnabledis driven by mount effects that can outlive the visualizer that asked for it — so with nosampleUpdatelistener at all, every frame still allocated a channel array (.map) plus a result object and walked every frame twice (peakOfthenrmsOf, ≈245k typed-array reads/second on a stereo source), only foremitSampleto then drop it at the silence gate. The handler now returns onhasListeners("sampleUpdate")as its first statement (the same gatetimeUpdatealready had), fuses peak and RMS into ONE pass over the frames (≈123k reads/second, byte-identical results — the per-channel clamps land exactly wherepeakOf/rmsOfapplied them), and refills a reused channel list + envelope instead of allocating two objects per frame.AudioSampleDataalready documented its buffers as read-synchronously; that now explicitly covers the channel list too.mixChannelstakes an optional result object for the same reason. Time labels subscribed to a raw float although `formatTime` floors to seconds.useAudioState(s => s.currentTime)failsObject.ison 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 (TimeCurrentandTimeDisplayon<Audio>,<Video>and<AudioPlaylist>) now selectMath.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 trackingProxyinrunTracked, a[...keys].map(String).sort().join()inregister(), then a secondProxy+ 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 aWeakMap(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.RRectXYeach return a real host object — a JSIHostObjectholding a C++shared_ptron native, aJsiSkRectwrapping a freshFloat32Arrayon web — sovariant="mirror"at the defaultcount=48created 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 resultingSkRRectcomes from the identicalSkRRect::MakeRectXYcall, and a zero radius takesaddRect, which is what Skia'saddRRectdegenerates 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. AuseDerivedValuepublishedcountfresh{kind,x,y,w,h,r}objects plus a fresh array into a SharedValue per frame (≈2,900 objects/second at the defaultcount, plus another array whenevergain/floor/reversewas set), read once by the two path worklets and thrown away. Each variant now also exists as a writer into a flatFloat64Arraythat 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 theSkPathitself.strokeWidthno longer builds acount-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.Float64Arrayrather thanFloat32Arrayso the coordinates round-trip exactly.VisualizerVariant,registerVisualizerVariantand 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'suseClock(), whose frame callback runs for as long as the component is mounted; every tick dirtied the uniforms mapper, soprefers-reduced-motion(which pins the drift att = 0) andspeed={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.buildMeshUniformskeeps its exact allocating shape for other callers, over the same single implementation of the math. Also, in the same spirit: -useVisualizerSourcebuilt 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.emitcopied its listenerSetinto a fresh array on every emit — including per audio frame. The single-listener case (a visualizer onsampleUpdate, a scrubber ontimeUpdate) 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 — aSetgrown during iteration yields the new entries; there is a test for exactly that). - The mic-stream reductions (frameFromTimeDomain, nativeuseAudioStream) 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 toonLeveland to React state). No public API changes in either package. Deferred: movinguseLevelTransition'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 sharedArrayBufferwould skip both). The allocation counts are certain, but it touches the documented Reanimated-4 SharedValue rules, changes the publicSharedValue<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 removedconfig/themes.tscalledcreateThemes(...)without acomponentThemesargument, so it inherited Tamagui'sdefaultComponentThemes— a 19-entry map that is itself@deprecatedupstream ("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 | resolvedVariables | 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: falseis set both in the stockconfig/themes.tsand increateTheme(), so a consumer-built config has the same theme set as the shipped one (24 names there — the stock 22 plus thebrandalias 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">, athemesoverride keyeddark_gray_Card, or acreateTheme({ 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'sstyled()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 acrosslight,dark,light_blueanddark_red(100 probes, 0 differences): | component | template | change | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | |ListItem| surface1 | none — the frame paints nothing, andsurface*leaves$color1…$color12and$coloralone | |Progress| surface1 | none — already explicit$colorN| |Button| surface3 |variant="default"border pinned$borderColor→$color7(the only variant reading$borderColor) | |Card| surface1 | frame +Card.Sectionborder pinned to$color2/$color5| |Checkbox| surface2 | unchecked box pinned to$color3/$color6| |SliderTrack| inverse | ramp steps written pre-mirrored ($color3→$color10; childSliderBar$color9→$color4) | |SliderThumb| inverse | pre-mirrored ($color1→$color12,$color9→$color4, hover$color10→$color3; childSliderLabelBubbletoo) | |SwitchThumb| inverse | childSwitchThumbIndicatorpre-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 fourinversecollisions were **reversing the entire$color1…$color12ramp** under those subtrees, not offsetting one step. SoTooltipwas authored$color9fill +$color1label (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/$color1etc.) is a deliberate follow-up. 2. **Descendants** of those nine frames no longer inherit the offset theme. A component placed inside aCardused to resolve$backgroundone 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_MEDIAalso 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 - **matchesQueryno longer re-parses on every call.** The nativeuseMediaQueryevaluated 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 (parseMediaQueryhands 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 anisReduceMotionEnabled()promise — 20 call sites meant 60 live listeners. Worse, every handler didsetEnv(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 throughuseSyncExternalStorewhose snapshot is the resolved **boolean**, so React bails per component unless that component's answer actually changed. - **useBreakpointrenders on band crossings, not resize pixels.** It was backed byuseViewportSize, whose untreatedresizelistener 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 — thoughresolveBreakpointyields only 8 values across 7 thresholds.mediaquerynow owns a viewport store that wakes subscribers only when the band changes, and the hook's snapshot is theBreakpointKeystring. (@knitui/hooks'useViewportSizeis untouched — it has other consumers that want the pixel width.) - **useSystemColorScheme** builds itsMediaQueryListonce at module scope instead of constructing a live, document-registered query object insidegetSnapshot(which React calls per render and per store-change check, twice over in StrictMode) and again insubscribe. No public API change in@knitui/mediaquery;useBreakpointkeeps its SSR contract (the server snapshot and first hydrating render both resolve theMediaQueryProvider` 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/graphicsis a minor rather than a patch because its@shopify/react-native-skiapeer is an exact pin and moves2.6.6→2.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to2.6.6will need to move to2.6.2to 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/graphicsis a minor rather than a patch because its@shopify/react-native-skiapeer is an exact pin and moves2.6.6→2.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to2.6.6will need to move to2.6.2to 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/graphicsis a minor rather than a patch because its@shopify/react-native-skiapeer is an exact pin and moves2.6.6→2.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to2.6.6will need to move to2.6.2to 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
-
SvgImageno longer gives up on a raster whose surface has not painted yet. Exhausting the eager capture window ended the job without ever callingonCapture, and nothing re-requests a raster — so the store slot stayed unresolved forever, no MapLibre image was registered, and everySymbolLayerdrawing 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 byfreezeOnBlur, 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-onlyconsole.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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes 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
VirtualListand reanimated-native scroll offsets - `VirtualList` — a new windowed list for large datasets. Renders only the visible slice (plus overscan) overScrollArea, 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` — newscrollValueX/scrollValueYprops 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 asButton/ActionIcon/Chip, so custom controls built on it no longer read as dead on press. DescendantpressStylemerges with it. - `Image` — fix a crash when passingtransitionon web. expo-image's numerictransitioncollided with Tamagui v2's reserved animation prop, which marked the image animated and made the web driver callgetComputedStyleon 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 themaplibre-gl5.24 andmaplibre-react-native11.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'sGeoJSONSourcestringifies in its render body (data={typeof data === "string" ? data : JSON.stringify(data)}). It ismemo'd, but that memo could never hold through our wrapper:onPresswas an inline arrow andchildrenis the consumer's inline JSX. So _any_ ancestor re-render — including asetStatefromonRegionIsChanging, 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.datadid not have to have changed. The wrapper now serializes once perdataidentity and hands upstream astring, so its ternary short-circuits to the same identity and Fabric diffs the prop to nothing;onPressis stable over a props ref. Every filterless layer forced a source reload at mount (web).useWebLayercalledmap.setFilter(id, filter ?? null). maplibre's guard isdeepEqual(layer.filter, filter),layer.filterisundefined, anddeepEqualbottoms out ata === b— sonullcompared FALSE and took the clearing branch:layer.setFilter(undefined)+_updateLayer(layer), which sets_updatedSources[source] = 'reload'and callstileManager.pause(). Every layer without afilterprop 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 withundefinedrather thannull. 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 camelCasestyleobject, so it fired every render and loopedsetPaintProperty/setLayoutPropertyover every key. maplibre does guard withdeepEqual, but only after_checkLoaded(),getLayer()andlayer.getPaintProperty(name)— and that last one isclone(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-imagepaid it in full.useWebLayernow diffs before calling into maplibre and memoizesresolvePaintLayout/ 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 newpaint/layoutper render, and upstream's guard isuseMemo(…, [props])wherepropsis a fresh rest-spread — so it never hits no matter what we pass. Every render therefore ranmergeStyleProps→transformStyle(aprocessColorper colour, anew BridgeValue(...).toJSON()walk over every expression array) and produced a newreactStyleprop that the native layer re-applied wholesale. A new shareduseNativeLayerPropshook 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 fromgetStyle(), i.e.Style.serialize()→_serializeByIds(this._order, returnClone=true)→ aclone()of every serialized layer plusmapObject(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-pointonPressconsumer. The layer registry already knows each layer'ssourceId, so the list is now derived from it and cached, invalidated by a registry revision counter and astyledataepoch. The hit-test itself is coalesced to one per animation frame and skipped whilemap.isMoving(). A full view-state event object plus timer churn on every raw `move` event (web).makeViewStateChangeEventran _before_ the 32 ms throttle check, so everymove(60+/s) did agetBounds()(four corner unprojections + aLngLatBoundsalloc), agetCenter(),getZoom/getBearing/getPitchand two object allocations — even for consumers with no region handlers at all. Each move also did aclearTimeoutplus a fresh 500 mssetTimeout. 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 whenonRegionDidChangeexists. The native side (where the event comes from native, so only the timer churn applied) got the same treatment.onRegionIsChangingis documented as the thing not tosetStatefrom 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, soRasterizerHostkept every<SvgXml>surface mounted for the map's whole lifetime — on native those are real view trees withcollapsable={false}that Android walks on every layout pass. Converselyrelease()deleted the slot _including_ its resolved bitmap, so unmounting and remounting an icon (a filter toggle) re-rasterized from scratch and drove aremoveImage/addImageround trip — and removing an image a symbol layer references forces MapLibre to redo symbol placement. Surfaces now unmount once their key resolves (never before, sorunCapture'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.runCaptureretriedtoDataURLonce 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:CamerawasJSON.stringify-ing its camera stop in the render body on every render (now a hand-rolled scalar/tuple compare),MapViewwas stringifyingattributionevery render (now memoized), and thesetStyleeffect'sJSON.stringify(mapStyle)— safe only because it was keyed onmapStyleidentity, 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 isexport *, the CJS interop Babel/Metro generaterequire()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
useAudioPlaylistControllerexposes the single-track player underneath the queue The hook now returns a third field,player: theAudioControllerslot 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 —useAudioSpectrumneedssetSamplingEnabledandsampleUpdate, 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 internaluseIdno caller could pass togetFacade, 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 throughcontroller: transport calls onplayer` move the slot without telling the queue, and the two snapshots then disagree. - Stop web video silently dropping a
play()made before its view existsexpo-video's web backend appliesplay()by iterating theHTMLVideoElements 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.autoPlaywas the plainest casualty: it ran in the constructor, before any view could exist. The controller now tracks a standing play INTENT — set byplay()/replay(), cleared bypause()and bydispose()— andattachViewre-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 apause— it is unmounted from the player first, so its events no longer count. The snapshot was left claimingplaying: trueover 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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
grabbingcursor while a mouse drag is actually under way The transform-mode track is dragged by RNGH'sGesture.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-onlyuseDragCursorpaintsgrabbingonce travel passes the same 5px threshold the drag itself uses, and restores whatever the carousel's own styling asked for on release. Deliberately no idlegrabhint: 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 apointerupwe never heard would leave the cursor stuck ongrabbing— as would unmounting mid-drag, which the cleanup now also covers.scrollMode="native"keeps getting its cursor fromuseDragScroll(it dragsscrollLeft, not the pan gesture), which is brought in line with the same rule: it no longer stamps an idlegrabon the track, and it restores the original cursor on release instead of resetting tograb— 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes 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
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes 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.
modeConfigis documented (and used everywhere) as an inline literal, so it was a fresh object every render anduseLayoutrebuilt the layout worklet every render. That closure is a dependency of every mounted slide'suseAnimatedStyle, so Reanimated tore down and re-installed the style mapper of EVERY mounted slide on every render, the slideReact.memonever held, and on web the painter ran a fullpaintAll()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 * 3in loop mode — and each one subscribed tooffsetseparately. A 30-item looped rail therefore turned a singleoffset.valuewrite 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/NativeTrackre-rendered on every settled index change (once per page crossed during a fling), rebuilding the element — and re-runninggetItem/keyExtractor— for every mounted slide. Both are nowReact.memo; combined with themodeConfigfix every prop is identity-stable on such an internal re-render, so the slides are left alone.useResolvedSourcenow 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: makingrenderItem'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 inlinerenderItemcloses over changed state — the classicFlatListextraDatabug. 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 aSharedValueinstead 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 ownuseAnimatedReactionoverprogress, 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 filluseAnimatedStyle. 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.Trackassigned its travel direction inside thesetCenterreducer, 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 correcteduseSharedValueListener's docstring, which claimedaddListenerworks "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.webfile, and the docstring now says so. The 3D layout worklets allocated a throwaway array per slide per frame.coverflow,flipandcubebuilt their transform as[...persp, …], which allocates an intermediatepersparray 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 theperspective > 0test 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,transformOriginandbackfaceVisibilitywere re-assigned every paint even though the defaultnormalLayoutemits 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.transformis deliberately still written unconditionally:Item.webalso writes it viainitialStyle, 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'sselectedflips, but every dot rebuilt itsuseReducedTransitionand its TamaguiCarouselDot.Dotis nowReact.memo'd, with the row stabilisingonPress(every caller writes it inline).renderDotis deliberately NOT stabilised — an inline renderer closes over its own state, and freezing it is theFlatListextraDatatrap. - Stop the icon barrel from being pulled into every component
@knitui/componentsdragged 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 ininternal/ControlIconProvider.tsx—import { 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 fromButton,Chip,Accordionand friends, every glyph became part of the graph. Walking the import graph from each package entry, honouringexportsconditions and.web/.nativeorder: | 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/context—IconProvider,useIconContextand their types -@knitui/icons/types—IconProps,IconNode,IconComponent,IconTypePer-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 ESLintno-restricted-importsguardrail ineslint.config.base.mjsblocks the bare@knitui/icons/@knitui/emojispecifier in shippedsrc/**so this cannot regress; stories, tests and the private@knitui/demogalleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: -sideEffectswas missing from@knitui/media,@knitui/sheetand@knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it.mediaandsheetare declaredsideEffects: false(audited: their only module-scope statements are pure const initialisers and adisplayNameassignment — the oneregisterProcessor()call inmedialives inside an AudioWorklet source string, not in the module).pluginslists just itsbabel-pluginentry, which really does mutateprocess.env.TAMAGUI_IGNORE_BUNDLE_ERRORSon load. -@knitui/plugins/next-pluginnow setsexperimental.optimizePackageImportsfor@knitui/icons,@knitui/emoji,@knitui/components,@knitui/datesand@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, andtranspilePackagesmeans 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. Anyexperimentalthe app already set is merged, not replaced, and its ownoptimizePackageImportsentries 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/componentsdragged 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 ininternal/ControlIconProvider.tsx—import { 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 fromButton,Chip,Accordionand friends, every glyph became part of the graph. Walking the import graph from each package entry, honouringexportsconditions and.web/.nativeorder: | 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/context—IconProvider,useIconContextand their types -@knitui/icons/types—IconProps,IconNode,IconComponent,IconTypePer-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 ESLintno-restricted-importsguardrail ineslint.config.base.mjsblocks the bare@knitui/icons/@knitui/emojispecifier in shippedsrc/**so this cannot regress; stories, tests and the private@knitui/demogalleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: -sideEffectswas missing from@knitui/media,@knitui/sheetand@knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it.mediaandsheetare declaredsideEffects: false(audited: their only module-scope statements are pure const initialisers and adisplayNameassignment — the oneregisterProcessor()call inmedialives inside an AudioWorklet source string, not in the module).pluginslists just itsbabel-pluginentry, which really does mutateprocess.env.TAMAGUI_IGNORE_BUNDLE_ERRORSon load. -@knitui/plugins/next-pluginnow setsexperimental.optimizePackageImportsfor@knitui/icons,@knitui/emoji,@knitui/components,@knitui/datesand@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, andtranspilePackagesmeans 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. Anyexperimentalthe app already set is merged, not replaced, and its ownoptimizePackageImportsentries 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 onerequestAnimationFrame. The capture-phasescrolllistener fires for every scrollable ancestor in the app, and each trigger ran a fullgetBoundingClientRect→computePosition→setStatecycle: 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 stoppedJSON.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 itsrunOnJShop 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 webResizeObserveris 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 aScrollArea, happened mid-fling on every window advance. Looping animations —Skeleton,Loader,ProgressandIndicatordeclare 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 awithRepeatre-evaluating a worklet every frame on native. ALoaderpaid for four, a staticIndicatorand a non-animateSkeletonfor one each. Loops are now scheduled only when they actually animate. `useReducedMotion` — one shared subscription per app instead of onematchMedia/AccessibilityInfolistener 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 aResizeObserver/onLayoutre-fire from a visibility flip or a sibling's reflow no longer re-rendersCollapse/Spoiler/Marqueecontent. `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.animationConfigis a flat record that every call site writes inline, and it is a dependency ofuseSheetDrag'suseMemo— so a fresh identity rebuilt theGesture.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 internaluseStableRecord(with tests) — the sibling of carousel'smodeConfigstabilisation and map'suseStableStyleValue.withAnimation/gestureConfigare 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 anenabledoption. - 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
typesentry 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-nativeanddefaultall still point intosrc), buttypespointed there too — so a consumer'stsctypechecked 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, sinceskipLibCheckdoes not apply to.tssource files. Resolvingtypesto real.d.tsfiles makes declaration handling both faster and inert.@knitui/iconsalso addslib/typescripttofiles; its declarations were previously built but never published, so the newtypespath would not have existed in the tarball.@knitui/emojideliberately keepstypeson its source: its per-emoji modules ship as pre-generated.js/.d.tspairs insidesrc, whichtscdoes 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/componentsdragged 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 ininternal/ControlIconProvider.tsx—import { 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 fromButton,Chip,Accordionand friends, every glyph became part of the graph. Walking the import graph from each package entry, honouringexportsconditions and.web/.nativeorder: | 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/context—IconProvider,useIconContextand their types -@knitui/icons/types—IconProps,IconNode,IconComponent,IconTypePer-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 ESLintno-restricted-importsguardrail ineslint.config.base.mjsblocks the bare@knitui/icons/@knitui/emojispecifier in shippedsrc/**so this cannot regress; stories, tests and the private@knitui/demogalleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: -sideEffectswas missing from@knitui/media,@knitui/sheetand@knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it.mediaandsheetare declaredsideEffects: false(audited: their only module-scope statements are pure const initialisers and adisplayNameassignment — the oneregisterProcessor()call inmedialives inside an AudioWorklet source string, not in the module).pluginslists just itsbabel-pluginentry, which really does mutateprocess.env.TAMAGUI_IGNORE_BUNDLE_ERRORSon load. -@knitui/plugins/next-pluginnow setsexperimental.optimizePackageImportsfor@knitui/icons,@knitui/emoji,@knitui/components,@knitui/datesand@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, andtranspilePackagesmeans 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. Anyexperimentalthe app already set is merged, not replaced, and its ownoptimizePackageImportsentries 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
setSamplingEnabledis driven by mount effects that can outlive the visualizer that asked for it — so with nosampleUpdatelistener at all, every frame still allocated a channel array (.map) plus a result object and walked every frame twice (peakOfthenrmsOf, ≈245k typed-array reads/second on a stereo source), only foremitSampleto then drop it at the silence gate. The handler now returns onhasListeners("sampleUpdate")as its first statement (the same gatetimeUpdatealready had), fuses peak and RMS into ONE pass over the frames (≈123k reads/second, byte-identical results — the per-channel clamps land exactly wherepeakOf/rmsOfapplied them), and refills a reused channel list + envelope instead of allocating two objects per frame.AudioSampleDataalready documented its buffers as read-synchronously; that now explicitly covers the channel list too.mixChannelstakes an optional result object for the same reason. Time labels subscribed to a raw float although `formatTime` floors to seconds.useAudioState(s => s.currentTime)failsObject.ison 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 (TimeCurrentandTimeDisplayon<Audio>,<Video>and<AudioPlaylist>) now selectMath.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 trackingProxyinrunTracked, a[...keys].map(String).sort().join()inregister(), then a secondProxy+ 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 aWeakMap(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.RRectXYeach return a real host object — a JSIHostObjectholding a C++shared_ptron native, aJsiSkRectwrapping a freshFloat32Arrayon web — sovariant="mirror"at the defaultcount=48created 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 resultingSkRRectcomes from the identicalSkRRect::MakeRectXYcall, and a zero radius takesaddRect, which is what Skia'saddRRectdegenerates 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. AuseDerivedValuepublishedcountfresh{kind,x,y,w,h,r}objects plus a fresh array into a SharedValue per frame (≈2,900 objects/second at the defaultcount, plus another array whenevergain/floor/reversewas set), read once by the two path worklets and thrown away. Each variant now also exists as a writer into a flatFloat64Arraythat 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 theSkPathitself.strokeWidthno longer builds acount-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.Float64Arrayrather thanFloat32Arrayso the coordinates round-trip exactly.VisualizerVariant,registerVisualizerVariantand 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'suseClock(), whose frame callback runs for as long as the component is mounted; every tick dirtied the uniforms mapper, soprefers-reduced-motion(which pins the drift att = 0) andspeed={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.buildMeshUniformskeeps its exact allocating shape for other callers, over the same single implementation of the math. Also, in the same spirit: -useVisualizerSourcebuilt 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.emitcopied its listenerSetinto a fresh array on every emit — including per audio frame. The single-listener case (a visualizer onsampleUpdate, a scrubber ontimeUpdate) 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 — aSetgrown during iteration yields the new entries; there is a test for exactly that). - The mic-stream reductions (frameFromTimeDomain, nativeuseAudioStream) 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 toonLeveland to React state). No public API changes in either package. Deferred: movinguseLevelTransition'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 sharedArrayBufferwould skip both). The allocation counts are certain, but it touches the documented Reanimated-4 SharedValue rules, changes the publicSharedValue<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
SvgImageicons rendering larger on iOS than on Android/web. react-native-svg'stoDataURLbakes the device scale (UIScreen.scale, e.g. 3×) into the rasterized bitmap on iOS but not on Android/web, so registering the icon withscale = pixelRatioleft iOS iconsUIScreen.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 logicalwidth/heightidentically 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/graphicsis a minor rather than a patch because its@shopify/react-native-skiapeer is an exact pin and moves2.6.6→2.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to2.6.6will need to move to2.6.2to 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 → 57 —
react-native0.85.3 → 0.86.0,react-native-reanimated4.3.1 → 4.5.0,react-native-worklets0.8.3 → 0.10.0,react-native-gesture-handler~2.31 → ~2.32,babel-preset-expo→ ^57, and allexpo-*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/componentsnow depends onexpo-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/medianow depends onexpo-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 apptsc`, 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 richerDeckCardState(animated stackdepth+ live drag) — the deck's analogue of the carousel's scalarprogress. Includes per-direction stamps (customrenderStampor built-instampLabels), an imperativeref(swipe/swipeLeft/swipeRight/swipeUp/getActiveIndex),onSwipe*/onActiveIndexChange/onEmptycallbacks,renderEmpty, a mounted-windowstackSize, and the kit's standardstylesper-slot map (root/card/stamp). Additive — no changes to the existing<Carousel>/<Pagination>surface.
@knitui/map
- minor Fix native
SvgImage/Imagescrash and correct high-density icon sizing. - Crash fix.@maplibre/maplibre-react-nativeresolves an object-form imagesourceviaImage.resolveAssetSource, which returnsnullfor a bare string (adata:URI from the SVG rasterizer, or a remote URL) and then throwsTypeError: Cannot read property 'uri' of null. The nativeImagesadapter now wraps a stringsourceinto{ uri }so it resolves correctly. This is what broke SDF/object icons on Android and iOS. - Correct `pixelRatio` sizing. The rasterizer upscales the bitmap bypixelRatiofor crispness, but the extra density was never registered, so raster icons drewpixelRatio×too big (previously worked around withiconSize).SvgImagenow registers the bitmap's density asscale— passed through tomap.addImage({ pixelRatio })on web and to the native image source{ uri, scale }(iOSUIImage.scale/ Androidbitmap.setDensity) — so an icon renders at its logicalwidth/heightregardless ofpixelRatio. Behavior change: if you setpixelRatioand compensated with a fractionaliconSize(e.g.iconSize: 0.5forpixelRatio: 2), drop that compensation and useiconSize: 1(or your intended size). SDF icons at the defaultpixelRatioof 1 are unaffected.
@knitui/media
- minor Expose the media store for custom chrome, and harden the audio recorder - Selector hooks —
useMediaSelector,shallowEqualand theMediaStorecontract are now public from both@knitui/media/audioand@knitui/media/video, alongside per-surfaceuseAudioState,useVideoState,useRecorderStateandusePlaylistState(plusAudioContext). 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 exportrequestNotificationPermissionsAsync, 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 faultedstop()no longer strands the recorder mid-recordingor surfaces as an unhandled rejection; both land in the terminalerrorstate and recover on retry. - `shouldCorrectPitch` — seed the initial state from the browser default on web rather than from expo'splayer.shouldCorrectPitch, which readsfalseuntil the firstsetPlaybackRateand so misreported correction as off. - `useAudioStream` — document thatchannelsis native-only; the web backend always down-mixes to mono and reports1regardless 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 arestyled(Box)parts withflexShrink: 0, so a nestedSheet.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-slotstylesmap (styles={{ header, footer }}) alongside the existingroot/overlay/handleslots.
@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/accentspalettes (a hex seed with an auto-derived 12-step ramp, a@tamagui/colorsname,{ light, dark }seeds, or explicit ramps),includeDefaultAccents/includeTamaguiColors,radius/space/size/fontSizesscale presets or per-step overrides,zIndex, rawcolors,fonts,breakpoints/media,animations/shorthands/settings, andthemeBuilder/themes/tamaguiescape 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, andvalidateThemeOptions. Options are strictly validated (unknown option / scale step / preset, malformed hex, unknown color name, wrong-length ramp, reserved accent name, undefineddefaultFont) with actionable "did you mean …?" messages.<Provider>now accepts an optionalconfigprop (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). ShipsuseMediaQuery(matchMedia string or structured descriptor),useBreakpoint/useBreakpointValueover the shared@knitui/corebreakpoint scale, an optionalMediaQueryProviderwithUser-Agentdevice seeding for SSR, and a pure query engine (parseMediaQuery/matchesQuery/queryToString).@knitui/corenow re-exports itsbreakpointsscale.
@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.tsoutput so the ~3.8k modules stay tree-shakeable and cheap to typecheck. Exposes namedEmoji*components, per-emoji subpath imports, a dynamic name-basedEmoji, and anEmojiProviderfor 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
loopinscrollMode="native"Native scroll mode now honoursloopinstead of forcing it off (and no longer warns whenloopis requested). Looping is realised by cloning the data ringLOOP_COPIEStimes 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. Programmaticnext/prev/scrollTotravel 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-svgon both web and native (identical output on each platform), then drawn on the GPU as MapLibreSymbolLayericons — 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 dedicatedRasterizerHostthat 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: -SvgImagenow also accepts auri— an.svg/data:image/svg+xmlURL is fetched and rasterized; any other URL (.png, …) is registered directly. Inlinesvgand pre-rasterizedsourcestill work, so this is backwards compatible. - NewSvgImagescomponent registers several icons at once — handy with a data-driveniconImageexpression for many marker types in a single GPU layer. - New exports:useRasterizedSvg,resolveSvgSize,useSvgMarkup,isSvgMarkup,isSvgUri,resolvePassthrough, and theSvgImageEntry/SvgImagesPropstypes. Note: because web now rasterizes throughreact-native-svg, consuming the map on web requires the standardreact-native→react-native-webalias (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 → 57 —
react-native0.85.3 → 0.86.0,react-native-reanimated4.3.1 → 4.5.0,react-native-worklets0.8.3 → 0.10.0,react-native-gesture-handler~2.31 → ~2.32,babel-preset-expo→ ^57, and allexpo-*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/componentsnow depends onexpo-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/medianow depends onexpo-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 apptsc`, 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.ScrollViewnested 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.ScrollViewis a purpose-builtAnimated.ScrollViewbound to the sheet'sGesture.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 aScrollAreawrapper (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). ShipsuseMediaQuery(matchMedia string or structured descriptor),useBreakpoint/useBreakpointValueover the shared@knitui/corebreakpoint scale, an optionalMediaQueryProviderwithUser-Agentdevice seeding for SSR, and a pure query engine (parseMediaQuery/matchesQuery/queryToString).@knitui/corenow re-exports itsbreakpointsscale. - 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 --checkreports the workspace aligned): -react-native0.86.0 → 0.86.2 (and@react-native/metro-configto match; both stay pinned as singletons in the rootpnpm.overrides) -react-native-reanimated4.5.0 → 4.5.1 andreact-native-worklets0.10.0 → 0.10.1 — the versions Expo SDK 57 expects -expo57.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-expo57.0.3 → 57.0.5, Storybook 10.5.3 → 10.5.5,@vitejs/plugin-react6.0.3 → 6.0.4,next16.2.10 → 16.2.12,@react-navigation/*patch bumps The vendoredexpo-modules-corepatch was re-pointed from 57.0.6 to 57.0.8 and still applies cleanly.expo-audiodeliberately 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/componentsdragged 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 ininternal/ControlIconProvider.tsx—import { 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 fromButton,Chip,Accordionand friends, every glyph became part of the graph. Walking the import graph from each package entry, honouringexportsconditions and.web/.nativeorder: | 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/context—IconProvider,useIconContextand their types -@knitui/icons/types—IconProps,IconNode,IconComponent,IconTypePer-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 ESLintno-restricted-importsguardrail ineslint.config.base.mjsblocks the bare@knitui/icons/@knitui/emojispecifier in shippedsrc/**so this cannot regress; stories, tests and the private@knitui/demogalleries (which render the whole registry on purpose) are exempt. Bundler hygiene, same theme: -sideEffectswas missing from@knitui/media,@knitui/sheetand@knitui/plugins, so webpack/Next had to keep every module of those packages even when nothing referenced it.mediaandsheetare declaredsideEffects: false(audited: their only module-scope statements are pure const initialisers and adisplayNameassignment — the oneregisterProcessor()call inmedialives inside an AudioWorklet source string, not in the module).pluginslists just itsbabel-pluginentry, which really does mutateprocess.env.TAMAGUI_IGNORE_BUNDLE_ERRORSon load. -@knitui/plugins/next-pluginnow setsexperimental.optimizePackageImportsfor@knitui/icons,@knitui/emoji,@knitui/components,@knitui/datesand@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, andtranspilePackagesmeans 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. Anyexperimentalthe app already set is merged, not replaced, and its ownoptimizePackageImportsentries 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/graphicsis a minor rather than a patch because its@shopify/react-native-skiapeer is an exact pin and moves2.6.6→2.6.2, which is the version Expo SDK 57 expects. Consumers currently pinned to2.6.6will need to move to2.6.2to 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
globaltoglobalThisin the client bundlereact-native-reanimated's web build touches the Node-ismglobalat module-eval time, so importing it in the browser threwReferenceError: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. Thetypescriptbob target built withproject: tsconfig.build.json, whosetsconfig.build.jsonset norootDir. tsc therefore inferred the package root as the root and emitted the.d.tsfiles one level too deep — atlib/typescript/module/src/next.d.ts— while theexports[...].typespaths (correctly) pointed atlib/typescript/module/next.d.ts. Runtime JS resolved fine, but a consumer's type checker (bundler resolution) couldn't find the declarations. SettingrootDir: "src"intsconfig.build.jsonflattens the output to exactly whereexportsalready 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 (anAnimated.ScrollViewon 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, controlledindexand the imperativeref(next/prev/scrollTo) keep working.snapEnabled/pagingEnabled/overscrollEnabled,verticalanditemSize/itemWidth/itemHeightare honoured. Native mode mounts every slide (no windowed virtualization), forcesloopoff, and ignores the transitionmode/customAnimation(slides lay out in normal flow). Default staysscrollMode="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-agenttext-align: centeron 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'sstyle, so anyone who wants centred content still overrides it explicitly. Internally the button-host wiring now reuses the sharedwebButton()helper (correctly a no-op on native) instead of hardcodingrender="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, soSvgImagerasterizes the SVG to a PNG throughreact-native-svg'sSvg.toDataURL. The capture fired once, synchronously, in the offscreen view'sonLayout— 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 arequestAnimationFrameretry loop that waits until the ref is attached andtoDataURLreturns real bytes before registering the image, and the offscreen host is positioned off-screen instead ofopacity: 0(an alpha-0 source view can snapshot blank on Android). Web is unchanged. Also fix a web layer-update gap: apaint/layoutkey present in a layer's initial config (e.g. aSymbolLayericonRotate) 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