Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright 2025 The Sparkling Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import SparklingMethod
import Testing
import UIKit

@testable import Sparkling

@Suite(.serialized)
@MainActor
struct SPKThemePreferenceTests {
@Test func defaultsToFollowSystemAndPersistsPreference() {
let userDefaults = self.makeUserDefaults()
let manager = SPKThemePreferenceManager(userDefaults: userDefaults)

#expect(manager.preference == .followSystem)

manager.setPreference(.dark)

#expect(manager.preference == .dark)
#expect(userDefaults.string(forKey: SPKThemePreferenceManager.userDefaultsKey) == SPKThemePreference.dark.rawValue)
}

@Test func defaultGlobalPropsInjectPersistedPreference() {
self.resetSharedPreference()
defer { self.resetSharedPreference() }

SPKThemePreferenceManager.shared.setPreference(.light)

let globalProps = SPKGlobalPropsUtils.defaultGlobalProps()

#expect(globalProps[SPKThemePreferenceManager.globalPropsKey] as? String == SPKThemePreference.light.rawValue)
}

@Test func defaultGlobalPropsInjectSystemTheme() {
let globalProps = SPKGlobalPropsUtils.defaultGlobalProps()

#expect(globalProps["theme"] as? String == SPKGlobalPropsUtils.systemTheme())
#expect(SPKGlobalPropsUtils.systemTheme(for: .light) == "light")
#expect(SPKGlobalPropsUtils.systemTheme(for: .dark) == "dark")
#expect(SPKGlobalPropsUtils.systemTheme(for: .unspecified) == "light")
}

@Test func themeMethodIsGloballyAutoDiscovered() {
let methodName = SPKSetThemePreferenceMethod.methodName()
MethodRegistry.global.unregister(methodName: methodName)
defer { MethodRegistry.global.unregister(methodName: methodName) }

MethodRegistry.autoRegisterGlobalMethods()

#expect(MethodRegistry.global.respondTo(methodName: methodName))
#expect(MethodRegistry.global.method(forName: methodName) is SPKSetThemePreferenceMethod)
}

@Test func broadcastsPreferenceToAllActiveContainers() {
let manager = SPKThemePreferenceManager(userDefaults: self.makeUserDefaults())
let firstContainer = RecordingWrapperView(containerID: "theme-container-1")
let secondContainer = RecordingWrapperView(containerID: "theme-container-2")
manager.register(firstContainer)
manager.register(secondContainer)

manager.setPreference(.dark)

let expected = [SPKThemePreferenceManager.globalPropsKey: SPKThemePreference.dark.rawValue]
#expect(firstContainer.globalPropsUpdates == [expected])
#expect(secondContainer.globalPropsUpdates == [expected])
}

@Test func setThemePreferenceMethodPersistsAndReturnsNormalizedPreference() {
self.resetSharedPreference()
defer { self.resetSharedPreference() }

let paramModel = SPKSetThemePreferenceParamModel()
paramModel.preference = " DARK "
var status: MethodStatus?
var result: SPKSetThemePreferenceResultModel?

SPKSetThemePreferenceMethod().call(withParamModel: paramModel) { callbackStatus, callbackResult in
status = callbackStatus
result = callbackResult as? SPKSetThemePreferenceResultModel
}

#expect(SPKSetThemePreferenceMethod.methodName() == "sparkling.setThemePreference")
#expect(status?.code == .succeeded)
#expect(result?.preference == SPKThemePreference.dark.rawValue)
#expect(SPKThemePreferenceManager.shared.preference == .dark)
}

@Test func setThemePreferenceMethodRejectsInvalidPreference() {
self.resetSharedPreference()
defer { self.resetSharedPreference() }

let paramModel = SPKSetThemePreferenceParamModel()
paramModel.preference = "sepia"
var status: MethodStatus?
var result: SPKMethodModel?

SPKSetThemePreferenceMethod().call(withParamModel: paramModel) { callbackStatus, callbackResult in
status = callbackStatus
result = callbackResult
}

#expect(status?.code == .invalidInputParameter)
#expect(result == nil)
#expect(SPKThemePreferenceManager.shared.preference == .followSystem)
}

private func makeUserDefaults() -> UserDefaults {
let suiteName = "SPKThemePreferenceTests.\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: suiteName)!
userDefaults.removePersistentDomain(forName: suiteName)
return userDefaults
}

private func resetSharedPreference() {
UserDefaults.standard.removeObject(forKey: SPKThemePreferenceManager.userDefaultsKey)
}
}

@MainActor
private final class RecordingWrapperView: UIView, SPKWrapperViewProtocol {
let containerID: String
var context: SPKHybridContext?
var loadState: SPKLoadState = .SPKLoadStateNotLoad
var rawView: UIView? { self }
var params: SPKHybridParams?
weak var lifeCycleDelegate: SPKWrapperViewLifecycleProtocol?
var anyMethodPipe: Any?
var estimatedProgress: Float = 0
var globalPropsUpdates: [[String: String]] = []

init(containerID: String) {
self.containerID = containerID
super.init(frame: .zero)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func load() {}

func reload(_ context: SPKHybridContext?) {
self.context = context
}

func send(event event: String, params: [String: Any]?, callback: ((Any?) -> Void)?) {}

func config(withParams params: SPKHybridParams?) {
self.params = params
}

func onshow(params: [AnyHashable: Any]) {}

func onHide(params: [AnyHashable: Any]) {}

func update(withGlobalProps globalProps: Any?) {
guard let globalProps = globalProps as? [String: String] else { return }
self.globalPropsUpdates.append(globalProps)
}

func config(withGlobalProps globalProps: Any?) {}
}
44 changes: 31 additions & 13 deletions packages/playground/src/lib/theme.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { createContext, useContext, useState, useCallback } from '@lynx-js/react'
// Copyright 2025 The Sparkling Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

export type ThemePreference = 'Auto' | 'Light' | 'Dark'
import { createContext, useContext, useState, useCallback, useEffect } from '@lynx-js/react'
import _pipe, { type EventCallback, type PipeResponse } from 'sparkling-method'
import { parseThemePreference, type ThemePreference } from './themePreference.js'

type SparklingPipe = typeof import('sparkling-method').default
const pipe = _pipe as unknown as SparklingPipe

export type { ThemePreference } from './themePreference.js'
export type ResolvedTheme = 'light' | 'dark'

interface ThemeContextValue {
Expand All @@ -26,23 +35,32 @@ function resolveTheme(preference: ThemePreference): ResolvedTheme {
}

function getInitialPreference(): ThemePreference {
const gp = (lynx.__globalProps || {}) as Record<string, any>
// The SDK puts force_theme_style in queryItems (nested), not as top-level preferredTheme.
// Check both locations for robustness.
const raw = gp.preferredTheme
|| (gp.queryItems as Record<string, any>)?.force_theme_style
|| 'Auto'
const lower = String(raw).toLowerCase()
if (lower === 'light') return 'Light'
if (lower === 'dark') return 'Dark'
return 'Auto'
return parseThemePreference(lynx.__globalProps)
}

export function ThemeProvider(props: { children: any }) {
const [preference, setPreferenceState] = useState<ThemePreference>(getInitialPreference)

useEffect(() => {
const handleGlobalPropsUpdated: EventCallback = () => {
setPreferenceState(getInitialPreference())
}
const listener = pipe.on('globalPropsUpdated', handleGlobalPropsUpdated)
return () => pipe.off('globalPropsUpdated', listener)
}, [])

const setPreference = useCallback((pref: ThemePreference) => {
setPreferenceState(pref)
pipe.call('sparkling.setThemePreference', {
preference: pref === 'Auto' ? 'follow-system' : pref.toLowerCase(),
}, (response: unknown) => {
const result = response as PipeResponse<{ preference?: string }>
if (result.code === 1) {
setPreferenceState(parseThemePreference({
...lynx.__globalProps,
preferredTheme: result.data?.preference,
}))
}
})
}, [])

const resolved = resolveTheme(preference)
Expand Down
24 changes: 24 additions & 0 deletions packages/playground/src/lib/themePreference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2025 The Sparkling Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import { describe, expect, it } from 'vitest'
import { parseThemePreference } from './themePreference.js'

describe('parseThemePreference', () => {
it('prefers force_theme_style over persisted preferredTheme', () => {
expect(parseThemePreference({
preferredTheme: 'dark',
queryItems: { force_theme_style: 'light' },
})).toBe('Light')
})

it('uses persisted preferredTheme when no force override exists', () => {
expect(parseThemePreference({ preferredTheme: 'dark' })).toBe('Dark')
})

it('maps follow-system and missing values to Auto', () => {
expect(parseThemePreference({ preferredTheme: 'follow-system' })).toBe('Auto')
expect(parseThemePreference()).toBe('Auto')
})
})
24 changes: 24 additions & 0 deletions packages/playground/src/lib/themePreference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2025 The Sparkling Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

export type ThemePreference = 'Auto' | 'Light' | 'Dark'

interface ThemeGlobalProps {
preferredTheme?: unknown
queryItems?: Record<string, unknown> | null
}

function normalizeThemePreference(value: unknown): ThemePreference | undefined {
const normalized = String(value ?? '').toLowerCase()
if (normalized === 'light') return 'Light'
if (normalized === 'dark') return 'Dark'
if (normalized === 'follow-system' || normalized === 'auto') return 'Auto'
return undefined
}

export function parseThemePreference(globalProps?: ThemeGlobalProps | null): ThemePreference {
return normalizeThemePreference(globalProps?.queryItems?.force_theme_style)
?? normalizeThemePreference(globalProps?.preferredTheme)
?? 'Auto'
}
7 changes: 0 additions & 7 deletions packages/playground/src/pages/main/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,12 @@ function HomePage(props: { showPage: boolean; topInset: number }) {

const handleItemTap = (bundle: string, title: string) => {
'background only'
// Pass the current resolved theme so sub-pages inherit the user's choice
const dark = resolved === 'dark'
navigate({
path: bundle,
options: {
params: {
title,
hide_nav_bar: 0,
container_bg_color: dark ? '#000000' : '#f0f2f5',
nav_bar_color: dark ? '#000000' : '#ffffff',
title_color: dark ? '#FFFFFF' : '#000000',
loading_bg_color: dark ? '#000000' : '#f0f2f5',
force_theme_style: resolved,
},
},
}, () => {})
Expand Down
4 changes: 0 additions & 4 deletions packages/playground/src/pages/nav-chain/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ function NavChainContent() {
params: {
title: `Stack Level ${depth + 1}`,
hide_nav_bar: 0,
container_bg_color: isDark ? '#000000' : '#f0f2f5',
nav_bar_color: isDark ? '#000000' : '#ffffff',
title_color: isDark ? '#FFFFFF' : '#000000',
force_theme_style: resolved,
depth: String(depth + 1),
from_depth: String(depth),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import com.tiktok.sparkling.hybridkit.config.SparklingHybridConfig
import com.tiktok.sparkling.hybridkit.lynx.HybridLynxKit
import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam
import com.tiktok.sparkling.hybridkit.service.HybridActivityStackManager
import com.tiktok.sparkling.hybridkit.theme.AbsSetThemePreferenceMethod
import com.tiktok.sparkling.hybridkit.theme.SetThemePreferenceMethod
import com.tiktok.sparkling.method.registry.core.SparklingBridgeManager
import com.tiktok.sparkling.method.runtime.depend.BridgeBaseRuntime

object HybridKit {
Expand All @@ -24,6 +27,12 @@ object HybridKit {
HybridActivityStackManager.init(application)
this.application = application
BridgeBaseRuntime.applicationContext = application.applicationContext
SparklingBridgeManager.registerIDLMethod(
AbsSetThemePreferenceMethod.METHOD_NAME,
clazz = SetThemePreferenceMethod::class.java,
) {
SetThemePreferenceMethod()
}
}

/**
Expand Down
Loading
Loading