diff --git a/packages/android-server/src/index.ts b/packages/android-server/src/index.ts index 27fa1048..fd4a05e2 100644 --- a/packages/android-server/src/index.ts +++ b/packages/android-server/src/index.ts @@ -5,8 +5,10 @@ export { AndroidOngoingNotification, renderAndroidOngoingNotificationPayload, renderAndroidOngoingNotificationPayloadToJson, + validateAndroidLayoutChildLimit, } from '@use-voltra/android/server' import { getAndroidComponentId } from '@use-voltra/android' +import { validateAndroidLayoutChildLimit } from '@use-voltra/android/server' import type { WidgetRenderRequest, WidgetUpdateExpressHandler, @@ -86,6 +88,7 @@ export const renderAndroidWidgetToJson = ( } rendered.variants = variantsMap + validateAndroidLayoutChildLimit(rendered) return rendered } diff --git a/packages/android-server/test/index.test.js b/packages/android-server/test/index.test.js index 8d4868b9..d339efa0 100644 --- a/packages/android-server/test/index.test.js +++ b/packages/android-server/test/index.test.js @@ -66,6 +66,36 @@ test('serializes Android widget variants before returning the shared response', assert.equal(calls[0].headers['x-voltra-request'], 'android-fetch') }) +test('warns about Android layout child limits when server rendering in development', () => { + const previousNodeEnv = process.env.NODE_ENV + const previousWarn = console.warn + const warnings = [] + process.env.NODE_ENV = 'development' + console.warn = (message) => warnings.push(message) + + try { + const children = Array.from({ length: 11 }, (_, index) => + React.createElement(VoltraAndroid.Text, { key: index }, String(index)) + ) + renderAndroidWidgetToString([ + { + size: { width: 150, height: 80 }, + content: React.createElement(VoltraAndroid.Column, null, children), + }, + ]) + + assert.equal(warnings.length, 1) + assert.match(warnings[0], /AndroidColumn has 11 direct children/) + } finally { + console.warn = previousWarn + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + } +}) + test('passes validateToken through unchanged and preserves null render results', async () => { const authCalls = [] const handler = createAndroidWidgetUpdateHandler({ diff --git a/packages/android/src/dev-environment.ts b/packages/android/src/dev-environment.ts new file mode 100644 index 00000000..9eac5a11 --- /dev/null +++ b/packages/android/src/dev-environment.ts @@ -0,0 +1,13 @@ +declare const __DEV__: boolean | undefined + +/** + * Uses React Native's compile-time development flag when available, while + * remaining safe to evaluate in Node-based renderers. + */ +export const isAndroidDevelopmentEnvironment = (): boolean => { + if (typeof __DEV__ !== 'undefined') { + return __DEV__ + } + + return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development' +} diff --git a/packages/android/src/internal.ts b/packages/android/src/internal.ts index 6b10d211..e87c8d40 100644 --- a/packages/android/src/internal.ts +++ b/packages/android/src/internal.ts @@ -1,2 +1,3 @@ export { renderAndroidViewToJson, renderAndroidWidgetToJson, renderAndroidWidgetToString } from './widgets/renderer.js' +export { validateAndroidLayoutChildLimit } from './payload/validate-layout-child-limit.js' export type { AndroidWidgetRenderOptions } from './widgets/renderer.js' diff --git a/packages/android/src/live-update/renderer.ts b/packages/android/src/live-update/renderer.ts index 0dfd98f9..7f272ae8 100644 --- a/packages/android/src/live-update/renderer.ts +++ b/packages/android/src/live-update/renderer.ts @@ -1,4 +1,5 @@ import { getAndroidComponentId } from '../payload/component-ids.js' +import { validateAndroidLayoutChildLimit } from '../payload/validate-layout-child-limit.js' import type { ComponentRegistry } from '../renderer/index.js' import { createVoltraRenderer } from '../renderer/index.js' import type { AndroidLiveUpdateJson, AndroidLiveUpdateVariants } from './types.js' @@ -28,6 +29,8 @@ export const renderAndroidLiveUpdateToJson = (variants: AndroidLiveUpdateVariant result.channelId = variants.channelId } + validateAndroidLayoutChildLimit(result) + return result } diff --git a/packages/android/src/payload/validate-layout-child-limit.ts b/packages/android/src/payload/validate-layout-child-limit.ts new file mode 100644 index 00000000..34089f87 --- /dev/null +++ b/packages/android/src/payload/validate-layout-child-limit.ts @@ -0,0 +1,112 @@ +import type { VoltraElementJson, VoltraNodeJson } from '@use-voltra/core' + +import { isAndroidDevelopmentEnvironment } from '../dev-environment.js' +import { getAndroidComponentId } from './component-ids.js' + +const MAX_LAYOUT_CHILDREN = 10 +const LAYOUT_COMPONENT_NAMES = new Map([ + [getAndroidComponentId('AndroidColumn'), 'AndroidColumn'], + [getAndroidComponentId('AndroidRow'), 'AndroidRow'], +]) + +type AndroidPayload = { + variants?: Record + collapsed?: VoltraNodeJson + expanded?: VoltraNodeJson + e?: VoltraNodeJson[] +} + +const getReference = (value: unknown): number | undefined => { + if (typeof value !== 'object' || value === null || Array.isArray(value) || !('$r' in value)) { + return undefined + } + + const reference = (value as { $r?: unknown }).$r + return typeof reference === 'number' ? reference : undefined +} + +const countDirectRenderedChildren = (node: VoltraNodeJson | undefined, sharedElements: VoltraNodeJson[]): number => { + const resolvingReferences = new Set() + + const count = (current: VoltraNodeJson | undefined): number => { + if (current === undefined) { + return 0 + } + + if (Array.isArray(current)) { + return current.reduce((total, child) => total + count(child), 0) + } + + const reference = getReference(current) + if (reference === undefined) { + return 1 + } + + if (resolvingReferences.has(reference)) { + return 0 + } + + resolvingReferences.add(reference) + const result = count(sharedElements[reference]) + resolvingReferences.delete(reference) + return result + } + + return count(node) +} + +/** Warns when a serialized Glance Row or Column exceeds its native child limit. */ +export const validateAndroidLayoutChildLimit = (payload: AndroidPayload): void => { + if (!isAndroidDevelopmentEnvironment()) { + return + } + + const sharedElements = payload.e ?? [] + const visitedNodes = new WeakSet() + + const visit = (node: unknown): void => { + if (typeof node !== 'object' || node === null) { + return + } + + if (visitedNodes.has(node)) { + return + } + visitedNodes.add(node) + + if (Array.isArray(node)) { + node.forEach(visit) + return + } + + const reference = getReference(node) + if (reference !== undefined) { + visit(sharedElements[reference]) + return + } + + if (!('t' in node) || typeof node.t !== 'number') { + return + } + + const element = node as VoltraElementJson + const componentName = LAYOUT_COMPONENT_NAMES.get(element.t) + if (componentName) { + const childCount = countDirectRenderedChildren(element.c, sharedElements) + if (childCount > MAX_LAYOUT_CHILDREN) { + console.warn( + `[Voltra] [Android] ${componentName} has ${childCount} direct children, exceeding Glance's ${MAX_LAYOUT_CHILDREN}-child limit. ` + + 'Extra children are truncated by Glance; use LazyColumn for dynamic or scrollable collections.' + ) + } + } + + visit(element.c) + Object.values(element.p ?? {}).forEach(visit) + } + + Object.values(payload.variants ?? {}).forEach(visit) + visit(payload.collapsed) + visit(payload.expanded) + sharedElements.forEach(visit) +} diff --git a/packages/android/src/renderer/renderer.ts b/packages/android/src/renderer/renderer.ts index d2c7e6fd..c87b785a 100644 --- a/packages/android/src/renderer/renderer.ts +++ b/packages/android/src/renderer/renderer.ts @@ -7,6 +7,7 @@ import { import type { ReactNode } from 'react' import { getAndroidComponentId } from '../payload/component-ids.js' +import { validateAndroidLayoutChildLimit } from '../payload/validate-layout-child-limit.js' import type { VoltraNodeJson } from '../types.js' export const androidComponentRegistry: ComponentRegistry = { @@ -17,7 +18,9 @@ export { VOLTRA_PAYLOAD_VERSION } export type { ComponentRegistry } export const renderAndroidVariantToJson = (element: ReactNode): VoltraNodeJson => { - return renderVariantToJson(element, androidComponentRegistry) + const rendered = renderVariantToJson(element, androidComponentRegistry) + validateAndroidLayoutChildLimit({ variants: { content: rendered } }) + return rendered } export const createVoltraRenderer = (componentRegistry: ComponentRegistry = androidComponentRegistry) => { diff --git a/packages/android/src/server.ts b/packages/android/src/server.ts index 30b0151f..2817a61f 100644 --- a/packages/android/src/server.ts +++ b/packages/android/src/server.ts @@ -16,4 +16,5 @@ export type { AndroidOngoingNotificationProgressSegment, } from './ongoing-notification/types.js' export { renderAndroidWidgetToString } from './widgets/renderer.js' +export { validateAndroidLayoutChildLimit } from './payload/validate-layout-child-limit.js' export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js' diff --git a/packages/android/src/widgets/renderer.ts b/packages/android/src/widgets/renderer.ts index da9d0b68..bf2ebe60 100644 --- a/packages/android/src/widgets/renderer.ts +++ b/packages/android/src/widgets/renderer.ts @@ -1,6 +1,7 @@ import { createElement, Fragment as ReactFragment, type ReactNode } from 'react' import { getAndroidComponentId } from '../payload/component-ids.js' +import { validateAndroidLayoutChildLimit } from '../payload/validate-layout-child-limit.js' import { ComponentRegistry, createVoltraRenderer } from '../renderer/renderer.js' import type { AndroidWidgetVariants } from './types.js' @@ -55,6 +56,7 @@ export const renderAndroidWidgetToJson = ( // Add variants as a nested object (expected by Kotlin parser) rendered.variants = variantsMap + validateAndroidLayoutChildLimit(rendered) return rendered } @@ -85,6 +87,7 @@ export const renderAndroidViewToJson = ( delete rendered.content rendered.variants = { content: node } + validateAndroidLayoutChildLimit(rendered) return rendered } diff --git a/packages/android/test/renderer.test.js b/packages/android/test/renderer.test.js index befad6cb..7e9dec41 100644 --- a/packages/android/test/renderer.test.js +++ b/packages/android/test/renderer.test.js @@ -3,6 +3,7 @@ const test = require('node:test') const React = require('react') const android = require('../build/commonjs/index.js') +const { validateAndroidLayoutChildLimit } = require('../build/commonjs/internal.js') const { createVoltraComponent } = require('../build/commonjs/jsx/createVoltraComponent.js') const { @@ -11,6 +12,7 @@ const { getAndroidComponentId, renderAndroidLiveUpdateToJson, renderAndroidLiveUpdateToString, + renderAndroidVariantToJson, renderAndroidViewToJson, renderAndroidWidgetToJson, renderAndroidWidgetToString, @@ -245,3 +247,133 @@ test('fails loudly for unknown Android component names', () => { } ) }) + +function withDevelopmentWarnings(run) { + const previousNodeEnv = process.env.NODE_ENV + const previousWarn = console.warn + const warnings = [] + + process.env.NODE_ENV = 'development' + console.warn = (message) => warnings.push(message) + + try { + run(warnings) + } finally { + console.warn = previousWarn + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + } +} + +function createLayoutChildren(count) { + return Array.from({ length: count }, (_, index) => + React.createElement(VoltraAndroid.Text, { key: index }, String(index)) + ) +} + +test('warns when an Android Column has more than 10 direct rendered children in development', () => { + withDevelopmentWarnings((warnings) => { + renderAndroidViewToJson(React.createElement(VoltraAndroid.Column, null, createLayoutChildren(11))) + + assert.equal(warnings.length, 1) + assert.match(warnings[0], /AndroidColumn/) + assert.match(warnings[0], /11 direct children/) + assert.match(warnings[0], /10-child limit/) + assert.match(warnings[0], /LazyColumn/) + }) +}) + +test('warns when an Android Row has more than 10 direct rendered children in development', () => { + withDevelopmentWarnings((warnings) => { + renderAndroidWidgetToJson([ + { + size: { width: 100, height: 100 }, + content: React.createElement(VoltraAndroid.Row, null, createLayoutChildren(11)), + }, + ]) + + assert.equal(warnings.length, 1) + assert.match(warnings[0], /AndroidRow/) + assert.match(warnings[0], /11 direct children/) + }) +}) + +test('checks nested Android Columns independently', () => { + withDevelopmentWarnings((warnings) => { + renderAndroidViewToJson( + React.createElement( + VoltraAndroid.Column, + null, + React.createElement(VoltraAndroid.Column, null, createLayoutChildren(11)) + ) + ) + + assert.equal(warnings.length, 1) + assert.match(warnings[0], /11 direct children/) + }) +}) + +test('does not warn for Android LazyColumn children', () => { + withDevelopmentWarnings((warnings) => { + renderAndroidViewToJson(React.createElement(VoltraAndroid.LazyColumn, null, createLayoutChildren(11))) + + assert.deepEqual(warnings, []) + }) +}) + +test('counts shared-element references as their resolved direct children once', () => { + withDevelopmentWarnings((warnings) => { + validateAndroidLayoutChildLimit({ + variants: { + content: { + t: getAndroidComponentId('AndroidColumn'), + c: { $r: 0 }, + }, + }, + e: [createLayoutChildren(11).map((child) => renderAndroidVariantToJson(child))], + }) + + assert.equal(warnings.length, 1) + assert.match(warnings[0], /11 direct children/) + }) +}) + +test('does not warn in production or test environments', () => { + const previousNodeEnv = process.env.NODE_ENV + const previousWarn = console.warn + const warnings = [] + + console.warn = (message) => warnings.push(message) + + try { + for (const environment of ['production', 'test']) { + process.env.NODE_ENV = environment + renderAndroidViewToJson(React.createElement(VoltraAndroid.Column, null, createLayoutChildren(11))) + } + + assert.deepEqual(warnings, []) + } finally { + console.warn = previousWarn + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + } +}) + +test('validates single-variant and live-update Android render payloads', () => { + withDevelopmentWarnings((warnings) => { + renderAndroidVariantToJson(React.createElement(VoltraAndroid.Column, null, createLayoutChildren(11))) + renderAndroidLiveUpdateToJson({ + expanded: React.createElement(VoltraAndroid.Row, null, createLayoutChildren(11)), + }) + + assert.equal(warnings.length, 2) + assert.match(warnings[0], /AndroidColumn/) + assert.match(warnings[1], /AndroidRow/) + }) +}) diff --git a/website/docs/v1/android/components/layout.md b/website/docs/v1/android/components/layout.md index 55937ddf..fb9f7ff8 100644 --- a/website/docs/v1/android/components/layout.md +++ b/website/docs/v1/android/components/layout.md @@ -6,6 +6,8 @@ Components that arrange other elements or provide structural grouping using Jetp A vertical container that arranges its children in a column. +> **Glance limit:** `Column` supports at most 10 direct rendered children. Jetpack Glance truncates extra children. Use `LazyColumn` for dynamic or scrollable collections. + **Parameters:** - `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`. @@ -17,6 +19,8 @@ A vertical container that arranges its children in a column. A horizontal container that arranges its children in a row. +> **Glance limit:** `Row` supports at most 10 direct rendered children. Jetpack Glance truncates extra children. Use `LazyColumn` for dynamic or scrollable collections. + **Parameters:** - `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`. diff --git a/website/docs/v2/android/components/layout.md b/website/docs/v2/android/components/layout.md index b0ede7a1..e1236dfa 100644 --- a/website/docs/v2/android/components/layout.md +++ b/website/docs/v2/android/components/layout.md @@ -6,6 +6,8 @@ Components that arrange other elements or provide structural grouping using Jetp A vertical container that arranges its children in a column. +> **Glance limit:** `Column` supports at most 10 direct rendered children. Jetpack Glance truncates extra children. Use `LazyColumn` for dynamic or scrollable collections. + **Parameters:** - `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`. @@ -17,6 +19,8 @@ A vertical container that arranges its children in a column. A horizontal container that arranges its children in a row. +> **Glance limit:** `Row` supports at most 10 direct rendered children. Jetpack Glance truncates extra children. Use `LazyColumn` for dynamic or scrollable collections. + **Parameters:** - `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`.