Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/android-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -86,6 +88,7 @@ export const renderAndroidWidgetToJson = (
}

rendered.variants = variantsMap
validateAndroidLayoutChildLimit(rendered)

return rendered
}
Expand Down
30 changes: 30 additions & 0 deletions packages/android-server/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 13 additions & 0 deletions packages/android/src/dev-environment.ts
Original file line number Diff line number Diff line change
@@ -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'
}
1 change: 1 addition & 0 deletions packages/android/src/internal.ts
Original file line number Diff line number Diff line change
@@ -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'
3 changes: 3 additions & 0 deletions packages/android/src/live-update/renderer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -28,6 +29,8 @@ export const renderAndroidLiveUpdateToJson = (variants: AndroidLiveUpdateVariant
result.channelId = variants.channelId
}

validateAndroidLayoutChildLimit(result)

return result
}

Expand Down
112 changes: 112 additions & 0 deletions packages/android/src/payload/validate-layout-child-limit.ts
Original file line number Diff line number Diff line change
@@ -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<string, VoltraNodeJson>
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<number>()

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<object>()

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)
}
5 changes: 4 additions & 1 deletion packages/android/src/renderer/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions packages/android/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
3 changes: 3 additions & 0 deletions packages/android/src/widgets/renderer.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -55,6 +56,7 @@ export const renderAndroidWidgetToJson = (

// Add variants as a nested object (expected by Kotlin parser)
rendered.variants = variantsMap
validateAndroidLayoutChildLimit(rendered)

return rendered
}
Expand Down Expand Up @@ -85,6 +87,7 @@ export const renderAndroidViewToJson = (

delete rendered.content
rendered.variants = { content: node }
validateAndroidLayoutChildLimit(rendered)

return rendered
}
Loading
Loading