diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14adad59..f360c83b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,8 @@ jobs: sparkling_method: - 'packages/sparkling-method/**' sparkling_router: - - 'packages/methods/sparkling-router/**' + - 'packages/sparkling-router/**' + - 'packages/sparkling-router-plugin/**' sparkling_storage: - 'packages/methods/sparkling-storage/**' sparkling_sdk: diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/close/RouterCloseMethod.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/close/RouterCloseMethod.kt index 53aab5a2..7dfaa5fb 100644 --- a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/close/RouterCloseMethod.kt +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/close/RouterCloseMethod.kt @@ -36,7 +36,7 @@ class RouterCloseMethod : AbsRouterCloseMethodIDL() { } val containerID = params.containerID - val animated = params.animated ?: true // Default to animated close + val animated = params.animated ?: false val success = try { diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/open/RouterOpenMethod.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/open/RouterOpenMethod.kt index f53b62fa..c34c9f0f 100644 --- a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/open/RouterOpenMethod.kt +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/open/RouterOpenMethod.kt @@ -72,11 +72,14 @@ class RouterOpenMethod : AbsRouterOpenMethodIDL() { val replace = params.replace ?: false val useSysBrowser = params.useSysBrowser ?: false + val animated = params.animated ?: false val extra = params.extra val extraInfo = mutableMapOf( "useSysBrowser" to useSysBrowser, + "animated" to animated, + "interceptor" to (params.interceptor ?: ""), "extra" to (extra ?: emptyMap()), ) @@ -103,20 +106,20 @@ class RouterOpenMethod : AbsRouterOpenMethodIDL() { try { when (replaceType) { ReplaceType.alwaysCloseBeforeOpen -> { - routerDepend.closeView(getSDKContext(), type) + routerDepend.closeView(getSDKContext(), type, animated = animated) routerDepend.openScheme(getSDKContext(), scheme, extraInfo, type, context = context) } ReplaceType.alwaysCloseAfterOpen -> { val opened = routerDepend.openScheme(getSDKContext(), scheme, extraInfo, type, context = context) - routerDepend.closeView(getSDKContext(), type) + routerDepend.closeView(getSDKContext(), type, animated = animated) opened } ReplaceType.onlyCloseAfterOpenSucceed -> { val opened = routerDepend.openScheme(getSDKContext(), scheme, extraInfo, type, context = context) if (opened) { - routerDepend.closeView(getSDKContext(), type) + routerDepend.closeView(getSDKContext(), type, animated = animated) } opened } diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/stack/AbsRouterStackMethodIDL.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/stack/AbsRouterStackMethodIDL.kt new file mode 100644 index 00000000..b505b53e --- /dev/null +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/stack/AbsRouterStackMethodIDL.kt @@ -0,0 +1,84 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.method.router.stack + +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodName +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamField +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamModel +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodResultModel +import com.tiktok.sparkling.method.registry.core.base.AbsSparklingIDLMethod +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseParamModel +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseResultModel + +abstract class AbsRouterStackMethodIDL : + AbsSparklingIDLMethod< + AbsRouterStackMethodIDL.IDLMethodStackParamModel, + AbsRouterStackMethodIDL.IDLMethodStackResultModel, + >() { + @IDLMethodName( + name = "router.stack", + params = [ + "command", + "path", + "search", + "bundle", + "scheme", + "presentation", + "entryId", + "entries", + "result", + "animated", + "usePrefetched", + ], + results = ["entryId", "state"], + ) + final override val name: String = "router.stack" + + @IDLMethodParamModel + interface IDLMethodStackParamModel : IDLMethodBaseParamModel { + @get:IDLMethodParamField(required = true, isGetter = true, keyPath = "command") + val command: String + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "path") + val path: String? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "search") + val search: Map? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "bundle") + val bundle: String? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "scheme") + val scheme: String? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "presentation") + val presentation: String? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "entryId") + val entryId: String? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "entries") + val entries: List>? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "result") + val result: Any? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "animated") + val animated: Boolean? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "usePrefetched") + val usePrefetched: Boolean? + } + + @IDLMethodResultModel + interface IDLMethodStackResultModel : IDLMethodBaseResultModel { + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "entryId") + @set:IDLMethodParamField(required = false, isGetter = false, keyPath = "entryId") + var entryId: String? + + @get:IDLMethodParamField(required = false, isGetter = true, keyPath = "state") + @set:IDLMethodParamField(required = false, isGetter = false, keyPath = "state") + var state: Map? + } +} diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/stack/RouterStackMethod.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/stack/RouterStackMethod.kt new file mode 100644 index 00000000..0a9029f0 --- /dev/null +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/stack/RouterStackMethod.kt @@ -0,0 +1,135 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.method.router.stack + +import com.tiktok.sparkling.method.registry.core.BridgePlatformType +import com.tiktok.sparkling.method.registry.core.IDLBridgeMethod +import com.tiktok.sparkling.method.registry.core.model.idl.CompletionBlock +import com.tiktok.sparkling.method.registry.core.utils.createXModel +import com.tiktok.sparkling.method.router.utils.RouterProvider +import com.tiktok.sparkling.method.router.utils.RouterStackCommand +import com.tiktok.sparkling.method.router.utils.RouterStackTarget + +class RouterStackMethod : AbsRouterStackMethodIDL() { + override fun handle( + params: IDLMethodStackParamModel, + callback: CompletionBlock, + type: BridgePlatformType, + ) { + val commandName = + params.command.ifBlank { + callback.onFailure(IDLBridgeMethod.INVALID_PARAM, "command must not be empty") + return + } + val routerDepend = + RouterProvider.hostRouterDepend ?: run { + callback.onFailure(IDLBridgeMethod.FAIL, "Router service not available") + return + } + val entries = mutableListOf() + if (commandName == "reset") { + params.entries.orEmpty().forEachIndexed { index, entry -> + val target = targetFrom(entry) + if (target == null) { + callback.onFailure( + IDLBridgeMethod.INVALID_PARAM, + "entries[$index] requires scheme, path, and a valid presentation", + ) + return + } + entries += target + } + if (entries.isEmpty()) { + callback.onFailure(IDLBridgeMethod.INVALID_PARAM, "reset requires entries") + return + } + } + val target = targetFrom(params) + if (commandName in setOf("push", "replace", "prefetch", "syncOwnLocation") && target == null) { + callback.onFailure(IDLBridgeMethod.INVALID_PARAM, "$commandName requires a valid target") + return + } + if (commandName == "popTo" && params.entryId.isNullOrBlank()) { + callback.onFailure(IDLBridgeMethod.INVALID_PARAM, "popTo requires entryId") + return + } + val command = + RouterStackCommand( + command = commandName, + target = target, + entryId = params.entryId, + entries = entries, + result = params.result, + animated = params.animated ?: true, + usePrefetched = params.usePrefetched ?: false, + ) + val result = + try { + routerDepend.executeStackCommand( + getSDKContext(), + command, + getSDKContext()?.context, + ) + } catch (error: Throwable) { + callback.onFailure( + IDLBridgeMethod.FAIL, + "Stack command failed: ${error.message ?: error::class.java.simpleName}", + ) + return + } + + if (result == null) { + callback.onFailure(IDLBridgeMethod.FAIL, "Stack protocol is not implemented by host") + return + } + if (!result.success) { + callback.onFailure(IDLBridgeMethod.FAIL, result.message) + return + } + callback.onSuccess( + IDLMethodStackResultModel::class.java.createXModel( + getSDKContext()?.containerID, + ).apply { + entryId = result.entryId + state = result.state + }, + ) + } + + private fun targetFrom(params: IDLMethodStackParamModel): RouterStackTarget? { + val path = params.path?.takeIf { it.isNotBlank() } ?: return null + val scheme = + params.scheme?.takeIf { it.isNotBlank() } + ?: if (params.command == "syncOwnLocation") "" else return null + val presentation = params.presentation ?: "push" + if (presentation !in setOf("push", "modal")) return null + return RouterStackTarget( + path = path, + search = params.search.orEmpty().mapValues { it.value.toString() }, + bundle = params.bundle.orEmpty(), + scheme = scheme, + presentation = presentation, + ) + } + + private fun targetFrom(value: Map): RouterStackTarget? { + val scheme = value["scheme"]?.toString()?.takeIf { it.isNotBlank() } ?: return null + val path = value["path"]?.toString()?.takeIf { it.isNotBlank() } ?: return null + val search = + (value["search"] as? Map<*, *>) + .orEmpty() + .mapNotNull { (key, item) -> + key?.toString()?.let { it to item.toString() } + }.toMap() + val presentation = value["presentation"]?.toString() ?: "push" + if (presentation !in setOf("push", "modal")) return null + return RouterStackTarget( + path = path, + search = search, + bundle = value["bundle"]?.toString().orEmpty(), + scheme = scheme, + presentation = presentation, + ) + } +} diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostRouterDepend.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostRouterDepend.kt index 4fcf505b..8582d8e7 100644 --- a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostRouterDepend.kt +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostRouterDepend.kt @@ -46,6 +46,12 @@ interface IHostRouterDepend { animated: Boolean? = false, ): Boolean + fun executeStackCommand( + bridgeContext: IBridgeContext?, + command: RouterStackCommand, + context: Context?, + ): RouterStackResult? = null + fun provideRouteOpenHandlerList(contextProviderFactory: ContextProviderFactory?): List = listOf() fun provideRouteOpenExceptionHandler(contextProviderFactory: ContextProviderFactory?): AbsRouteOpenHandler? = null diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/RouterStackModels.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/RouterStackModels.kt new file mode 100644 index 00000000..be68d20b --- /dev/null +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/RouterStackModels.kt @@ -0,0 +1,29 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.method.router.utils + +data class RouterStackTarget( + val path: String, + val search: Map, + val bundle: String, + val scheme: String, + val presentation: String, +) + +data class RouterStackCommand( + val command: String, + val target: RouterStackTarget?, + val entryId: String?, + val entries: List, + val result: Any?, + val animated: Boolean, + val usePrefetched: Boolean, +) + +data class RouterStackResult( + val success: Boolean, + val message: String, + val entryId: String? = null, + val state: Map? = null, +) diff --git a/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterCoverageTest.kt b/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterCoverageTest.kt index be956dc6..e927d4b9 100644 --- a/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterCoverageTest.kt +++ b/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterCoverageTest.kt @@ -276,9 +276,9 @@ class RouterCoverageTest { assertEquals(IDLBridgeMethod.FAIL, callback.failureCode) assertEquals("Failed to close current container", callback.failureMsg) - // animated default is true when null + // animated defaults to false on every platform verify(exactly = 1) { - hostRouter.closeView(bridgeContext, BridgePlatformType.LYNX, " ", true) + hostRouter.closeView(bridgeContext, BridgePlatformType.LYNX, " ", false) } } diff --git a/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterMethodUnitTest.kt b/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterMethodUnitTest.kt index b0a4abe8..1573a628 100644 --- a/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterMethodUnitTest.kt +++ b/packages/methods/sparkling-navigation/android/src/test/java/com/tiktok/sparkling/method/router/RouterMethodUnitTest.kt @@ -9,8 +9,11 @@ import com.tiktok.sparkling.method.router.close.AbsRouterCloseMethodIDL import com.tiktok.sparkling.method.router.close.RouterCloseMethod import com.tiktok.sparkling.method.router.open.AbsRouterOpenMethodIDL import com.tiktok.sparkling.method.router.open.RouterOpenMethod +import com.tiktok.sparkling.method.router.stack.AbsRouterStackMethodIDL +import com.tiktok.sparkling.method.router.stack.RouterStackMethod import com.tiktok.sparkling.method.router.utils.IHostRouterDepend import com.tiktok.sparkling.method.router.utils.RouterProvider +import com.tiktok.sparkling.method.router.utils.RouterStackResult import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -146,6 +149,43 @@ class RouterMethodUnitTest { } } + @Test + fun stackMethodReturnsNativeSnapshot() { + val hostRouter = mockk(relaxed = true) + every { + hostRouter.executeStackCommand(any(), any(), any()) + } returns + RouterStackResult( + success = true, + message = "ok", + entryId = "entry-2", + state = mapOf("version" to 2, "entries" to emptyList()), + ) + RouterProvider.hostRouterDepend = hostRouter + + val method = RouterStackMethod().apply { setBridgeContext(bridgeContext) } + val params = mockk(relaxed = true) + every { params.command } returns "push" + every { params.path } returns "/feed" + every { params.scheme } returns "hybrid://lynxview_page?bundle=feed.lynx.bundle" + every { params.bundle } returns "feed.lynx.bundle" + every { params.search } returns mapOf("sort" to "new") + every { params.entries } returns null + + val callback = StackCallbackRecorder() + method.handle(params, callback, BridgePlatformType.LYNX) + + assertEquals("entry-2", callback.successResult?.entryId) + assertEquals(2, callback.successResult?.state?.get("version")) + verify(exactly = 1) { + hostRouter.executeStackCommand( + bridgeContext, + match { it.command == "push" && it.target?.path == "/feed" }, + context, + ) + } + } + private class OpenCallbackRecorder : CompletionBlock { var successResult: AbsRouterOpenMethodIDL.IDLMethodOpenResultModel? = null var failureCode: Int? = null @@ -193,4 +233,26 @@ class RouterMethodUnitTest { override fun onRawSuccess(data: AbsRouterCloseMethodIDL.IDLMethodCloseResultModel?) = Unit } + + private class StackCallbackRecorder : CompletionBlock { + var successResult: AbsRouterStackMethodIDL.IDLMethodStackResultModel? = null + var failureCode: Int? = null + + override fun onSuccess( + result: AbsRouterStackMethodIDL.IDLMethodStackResultModel, + msg: String, + ) { + successResult = result + } + + override fun onFailure( + code: Int, + msg: String, + data: AbsRouterStackMethodIDL.IDLMethodStackResultModel?, + ) { + failureCode = code + } + + override fun onRawSuccess(data: AbsRouterStackMethodIDL.IDLMethodStackResultModel?) = Unit + } } diff --git a/packages/methods/sparkling-navigation/index.ts b/packages/methods/sparkling-navigation/index.ts index bf63b396..06ed6f0b 100644 --- a/packages/methods/sparkling-navigation/index.ts +++ b/packages/methods/sparkling-navigation/index.ts @@ -4,6 +4,37 @@ export * from './src/open/open'; export * from './src/close/close'; export * from './src/navigate/navigate'; +export { + STACK_CHANGED_EVENT, + getState, + nativeStack, + pop, + popTo, + prefetch, + push, + replace, + reset, + subscribeStackChanges, + syncOwnLocation, +} from './src/stack/stack'; export type { OpenRequest, OpenResponse, OpenOptions } from './src/open/open.d'; export type { CloseRequest, CloseResponse } from './src/close/close.d'; export type { NavigateRequest, NavigateResponse, NavigateOptions } from './src/navigate/navigate.d'; +export type { + NativeStackProtocol, + NavResult, + StackChangedEvent, + StackChangeReason, + StackEntry, + StackLocationRequest, + StackPopRequest, + StackPopToRequest, + StackPrefetchRequest, + StackPresentation, + StackPushRequest, + StackReplaceRequest, + StackResetEntry, + StackResetRequest, + StackState, + SyncOwnLocationRequest, +} from './src/stack/stack.types'; diff --git a/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethod+impl.swift b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethod+impl.swift new file mode 100644 index 00000000..16264a7b --- /dev/null +++ b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethod+impl.swift @@ -0,0 +1,40 @@ +// Copyright 2026 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 Foundation +import SparklingMethod + +extension StackMethod { + @objc public override func call( + withParamModel paramModel: Any, + completionHandler: CompletionHandlerProtocol + ) { + guard let params = paramModel as? StackMethodParamModel else { + completionHandler.handleCompletion( + status: .invalidParameter(message: "Invalid parameter model type"), + result: nil + ) + return + } + guard let command = params.command, !command.isEmpty else { + completionHandler.handleCompletion( + status: .invalidParameter(message: "command must be a non-empty string"), + result: nil + ) + return + } + guard let service = DIProviderRegistry.provider.pipeShared().resolve( + RouterStackService.self + ) else { + handleNotImplemented { status, result in + completionHandler.handleCompletion(status: status, result: result) + } + return + } + + service.performStackCommand(withParams: params) { status, result in + completionHandler.handleCompletion(status: status, result: result) + } + } +} diff --git a/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethod.swift b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethod.swift new file mode 100644 index 00000000..4b19be7a --- /dev/null +++ b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethod.swift @@ -0,0 +1,73 @@ +// Copyright 2026 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 Foundation +import SparklingMethod + +@objc(StackMethod) +public class StackMethod: PipeMethod { + public override var methodName: String { + return "router.stack" + } + + public override class func methodName() -> String { + return "router.stack" + } + + @objc public override var paramsModelClass: AnyClass { + return StackMethodParamModel.self + } + + @objc public override var resultModelClass: AnyClass { + return StackMethodResultModel.self + } +} + +@objc(StackMethodParamModel) +public class StackMethodParamModel: SPKMethodModel { + public override class func requiredKeyPaths() -> Set? { + return ["command"] + } + + @objc public var command: String? + @objc public var path: String? + @objc public var search: NSDictionary? + @objc public var bundle: String? + @objc public var scheme: String? + @objc public var presentation: String? + @objc public var entryId: String? + @objc public var entries: NSArray? + @objc public var result: Any? + @objc public var animated: Bool = true + @objc public var usePrefetched: Bool = false + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "command": "command", + "path": "path", + "search": "search", + "bundle": "bundle", + "scheme": "scheme", + "presentation": "presentation", + "entryId": "entryId", + "entries": "entries", + "result": "result", + "animated": "animated", + "usePrefetched": "usePrefetched", + ] + } +} + +@objc(StackMethodResultModel) +public class StackMethodResultModel: SPKMethodModel { + @objc public var entryId: String? + @objc public var state: NSDictionary? + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "entryId": "entryId", + "state": "state", + ] + } +} diff --git a/packages/methods/sparkling-navigation/ios/Sources/Core/Protocols/RouterStackService.swift b/packages/methods/sparkling-navigation/ios/Sources/Core/Protocols/RouterStackService.swift new file mode 100644 index 00000000..10648bb6 --- /dev/null +++ b/packages/methods/sparkling-navigation/ios/Sources/Core/Protocols/RouterStackService.swift @@ -0,0 +1,13 @@ +// Copyright 2026 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 Foundation +import SparklingMethod + +public protocol RouterStackService { + func performStackCommand( + withParams params: StackMethodParamModel, + completion: @escaping PipeMethod.CompletionBlock + ) +} diff --git a/packages/methods/sparkling-navigation/ios/SparklingMethodTests/SPKRouterTest.swift b/packages/methods/sparkling-navigation/ios/SparklingMethodTests/SPKRouterTest.swift index 45c87455..f69e4efd 100644 --- a/packages/methods/sparkling-navigation/ios/SparklingMethodTests/SPKRouterTest.swift +++ b/packages/methods/sparkling-navigation/ios/SparklingMethodTests/SPKRouterTest.swift @@ -116,4 +116,24 @@ class SPKRouterTest: XCTestCase { XCTAssertNotNil(model.extra) XCTAssertEqual(model.extra?["foo"] as? String, "bar") } + + func testStackMethodModelsAndMapping() throws { + let method = StackMethod() + XCTAssertEqual(method.methodName, "router.stack") + XCTAssertTrue(method.paramsModelClass is StackMethodParamModel.Type) + XCTAssertTrue(method.resultModelClass is StackMethodResultModel.Type) + + let model = try XCTUnwrap(try StackMethodParamModel.from(dict: [ + "command": "push", + "path": "/feed/42", + "search": ["sort": "new"], + "bundle": "feed.lynx.bundle", + "scheme": "hybrid://lynxview_page?bundle=feed.lynx.bundle", + "presentation": "push", + ])) + XCTAssertEqual(model.command, "push") + XCTAssertEqual(model.path, "/feed/42") + XCTAssertEqual(model.search?["sort"] as? String, "new") + XCTAssertEqual(model.bundle, "feed.lynx.bundle") + } } diff --git a/packages/methods/sparkling-navigation/jest.config.ts b/packages/methods/sparkling-navigation/jest.config.ts index 2f58f105..14d29c36 100644 --- a/packages/methods/sparkling-navigation/jest.config.ts +++ b/packages/methods/sparkling-navigation/jest.config.ts @@ -8,6 +8,9 @@ const config: Config = { testEnvironment: 'node', roots: ['/src'], testMatch: ['**/__tests__/**/*.test.ts'], + moduleNameMapper: { + '^sparkling-method$': '/src/__tests__/mocks/sparkling-method.js', + }, moduleFileExtensions: ['ts', 'js', 'json'], collectCoverageFrom: [ 'src/**/*.ts', diff --git a/packages/methods/sparkling-navigation/module.config.json b/packages/methods/sparkling-navigation/module.config.json index a63028ef..fc3d0679 100644 --- a/packages/methods/sparkling-navigation/module.config.json +++ b/packages/methods/sparkling-navigation/module.config.json @@ -20,12 +20,32 @@ "close": { "description": "Close the current page or route", "parameters": { - "result": "any", + "containerID": "string", "animated": "boolean" }, "returns": { "success": "boolean" } + }, + "stack": { + "description": "Execute a URL-first native container stack command", + "parameters": { + "command": "string", + "path": "string", + "search": "Record", + "bundle": "string", + "scheme": "string", + "presentation": "push | modal", + "entryId": "string", + "entries": "Array", + "result": "any", + "animated": "boolean", + "usePrefetched": "boolean" + }, + "returns": { + "entryId": "string", + "state": "StackState" + } } }, "android": { diff --git a/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap b/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap index a88375b0..11be06e7 100644 --- a/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap @@ -2,13 +2,33 @@ exports[`sparkling-navigation module exports snapshot testing for module structure should maintain consistent module export structure: module-export-structure 1`] = ` { - "exportCount": 3, + "exportCount": 14, "exportedKeys": [ + "STACK_CHANGED_EVENT", + "getState", + "nativeStack", + "pop", + "popTo", + "prefetch", + "push", + "replace", + "reset", + "subscribeStackChanges", + "syncOwnLocation", "open", "close", "navigate", ], "functionNames": [ + "getState", + "pop", + "popTo", + "prefetch", + "push", + "replace", + "reset", + "subscribeStackChanges", + "syncOwnLocation", "open", "close", "navigate", diff --git a/packages/methods/sparkling-navigation/src/__tests__/index.test.ts b/packages/methods/sparkling-navigation/src/__tests__/index.test.ts index aa034bb3..deef67c9 100644 --- a/packages/methods/sparkling-navigation/src/__tests__/index.test.ts +++ b/packages/methods/sparkling-navigation/src/__tests__/index.test.ts @@ -2,15 +2,20 @@ // Copyright (c) 2025 TikTok Pte. Ltd. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +jest.mock('sparkling-method', () => ({ + __esModule: true, + default: { + call: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, +})); -import { createMockPipe } from './test-utils'; import * as routerModule from '../../index'; import { open as openDirect } from '../open/open'; import { close as closeDirect } from '../close/close'; import { navigate as navigateDirect } from '../navigate/navigate'; -jest.mock('sparkling-method', () => ({ call: jest.fn() }), { virtual: true }); - describe('sparkling-navigation module exports', () => { describe('function exports', () => { it('should export open function', async () => { @@ -29,7 +34,20 @@ describe('sparkling-navigation module exports', () => { }); it('should export all required functions', async () => { - const expectedFunctions = ['open', 'close', 'navigate']; + const expectedFunctions = [ + 'open', + 'close', + 'navigate', + 'getState', + 'pop', + 'popTo', + 'prefetch', + 'push', + 'replace', + 'reset', + 'subscribeStackChanges', + 'syncOwnLocation', + ]; const moduleAny: Record = routerModule as unknown as Record; expectedFunctions.forEach(functionName => { expect(moduleAny[functionName]).toBeDefined(); @@ -50,7 +68,10 @@ describe('sparkling-navigation module exports', () => { CloseResponse, NavigateRequest, NavigateResponse, - NavigateOptions + NavigateOptions, + StackEntry, + StackState, + NativeStackProtocol } from '../../index' `; @@ -65,7 +86,22 @@ describe('sparkling-navigation module exports', () => { const moduleAny: Record = routerModule as unknown as Record; const exportedKeys = Object.keys(moduleAny); - const expectedExports = ['open', 'close', 'navigate']; + const expectedExports = [ + 'open', + 'close', + 'navigate', + 'STACK_CHANGED_EVENT', + 'getState', + 'nativeStack', + 'pop', + 'popTo', + 'prefetch', + 'push', + 'replace', + 'reset', + 'subscribeStackChanges', + 'syncOwnLocation', + ]; const unexpectedExports = exportedKeys.filter(key => expectedExports.indexOf(key) === -1); expect(unexpectedExports).toHaveLength(0); @@ -74,7 +110,7 @@ describe('sparkling-navigation module exports', () => { it('should export exactly the expected number of functions', async () => { const moduleAny: Record = routerModule as unknown as Record; const exportedFunctions = Object.keys(moduleAny).filter(key => typeof moduleAny[key] === 'function'); - expect(exportedFunctions).toHaveLength(3); // open, close and navigate + expect(exportedFunctions).toHaveLength(12); }); }); diff --git a/packages/methods/sparkling-navigation/src/__tests__/mocks/sparkling-method.js b/packages/methods/sparkling-navigation/src/__tests__/mocks/sparkling-method.js new file mode 100644 index 00000000..a2ea8b62 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/__tests__/mocks/sparkling-method.js @@ -0,0 +1,5 @@ +module.exports = { + call: jest.fn(), + on: jest.fn(), + off: jest.fn(), +}; diff --git a/packages/methods/sparkling-navigation/src/__tests__/stack/stack.test.ts b/packages/methods/sparkling-navigation/src/__tests__/stack/stack.test.ts new file mode 100644 index 00000000..b6715f7a --- /dev/null +++ b/packages/methods/sparkling-navigation/src/__tests__/stack/stack.test.ts @@ -0,0 +1,103 @@ +jest.mock('sparkling-method', () => ({ + __esModule: true, + default: { + call: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, +})); + +import pipe from 'sparkling-method'; +import { + getState, + push, + subscribeStackChanges, + syncOwnLocation, +} from '../../stack/stack'; + +const mockPipe = pipe as jest.Mocked; +const mockCall = mockPipe.call; +const mockOn = mockPipe.on; +const mockOff = mockPipe.off; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('native stack protocol', () => { + it('sends resolved hard-navigation metadata through one bridge method', async () => { + mockCall.mockImplementation((_method, _params, callback) => { + callback({ code: 1, msg: 'ok', data: { entryId: 'entry-2' } }); + }); + + await expect(push({ + path: '/feed/42', + search: { sort: 'new' }, + bundle: 'feed.lynx.bundle', + scheme: 'hybrid://lynxview_page?bundle=feed.lynx.bundle', + })).resolves.toEqual({ + code: 1, + msg: 'ok', + entryId: 'entry-2', + state: undefined, + }); + expect(mockCall).toHaveBeenCalledWith( + 'router.stack', + expect.objectContaining({ + command: 'push', + path: '/feed/42', + bundle: 'feed.lynx.bundle', + }), + expect.any(Function), + ); + }); + + it('returns getState data and rejects failed snapshots', async () => { + mockCall.mockImplementationOnce((_method, _params, callback) => { + callback({ + code: 1, + msg: 'ok', + data: { state: { version: 2, entries: [] } }, + }); + }); + await expect(getState()).resolves.toEqual({ version: 2, entries: [] }); + + mockCall.mockImplementationOnce((_method, _params, callback) => { + callback({ code: 0, msg: 'not available' }); + }); + await expect(getState()).rejects.toThrow('not available'); + }); + + it('normalizes native event envelopes and unsubscribes', () => { + const listener = jest.fn(); + const unsubscribe = subscribeStackChanges(listener); + const nativeListener = mockOn.mock.calls[0][1]; + nativeListener([{ + code: 1, + data: { + state: { version: 3, entries: [] }, + reason: 'user-back-gesture', + }, + }]); + + expect(listener).toHaveBeenCalledWith({ + state: { version: 3, entries: [] }, + reason: 'user-back-gesture', + }); + unsubscribe(); + expect(mockOff).toHaveBeenCalledWith('router.stackchanged', nativeListener); + }); + + it('uses fire-and-forget semantics for own-location synchronization', () => { + mockCall.mockImplementation((_method, _params, callback) => { + callback({ code: 1, msg: 'ok' }); + }); + syncOwnLocation({ path: '/feed/42', search: {} }); + + expect(mockCall).toHaveBeenCalledWith( + 'router.stack', + { command: 'syncOwnLocation', path: '/feed/42', search: {} }, + expect.any(Function), + ); + }); +}); diff --git a/packages/methods/sparkling-navigation/src/stack/stack.ts b/packages/methods/sparkling-navigation/src/stack/stack.ts new file mode 100644 index 00000000..0e43cba0 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/stack.ts @@ -0,0 +1,165 @@ +// Copyright 2026 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 pipe from 'sparkling-method'; +import type { + NativeStackProtocol, + NavResult, + StackChangedEvent, + StackPopRequest, + StackPopToRequest, + StackPrefetchRequest, + StackPushRequest, + StackReplaceRequest, + StackResetRequest, + StackState, + SyncOwnLocationRequest, +} from './stack.types'; + +export const STACK_CHANGED_EVENT = 'router.stackchanged'; +const STACK_METHOD = 'router.stack'; + +type StackCommand = + | 'push' + | 'pop' + | 'popTo' + | 'replace' + | 'reset' + | 'getState' + | 'prefetch' + | 'syncOwnLocation'; + +interface PipeResponse { + code?: number; + msg?: string; + data?: { + entryId?: string; + state?: StackState; + }; +} + +const localListeners = new Set<(event: StackChangedEvent) => void>(); + +function normalizeResult(value: unknown): NavResult { + const response = (value ?? {}) as PipeResponse; + const code = typeof response.code === 'number' ? response.code : -1; + return { + code, + msg: response.msg ?? (code === 1 ? 'ok' : 'Unknown error'), + entryId: response.data?.entryId, + state: response.data?.state, + }; +} + +function callStack(command: StackCommand, payload: Record = {}): Promise { + return new Promise((resolve) => { + pipe.call(STACK_METHOD, { command, ...payload }, (value: unknown) => { + resolve(normalizeResult(value)); + }); + }); +} + +function isStackState(value: unknown): value is StackState { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return typeof candidate.version === 'number' && Array.isArray(candidate.entries); +} + +function unwrapEvent(value: unknown): StackChangedEvent | null { + let candidate = Array.isArray(value) ? value[0] : value; + if (candidate && typeof candidate === 'object' && 'data' in candidate) { + candidate = (candidate as { data?: unknown }).data; + } + if (!candidate || typeof candidate !== 'object') { + return null; + } + const event = candidate as Partial; + if (!isStackState(event.state) || typeof event.reason !== 'string') { + return null; + } + return event as StackChangedEvent; +} + +/** @internal Used by the web bridge, which has no Lynx GlobalEventEmitter. */ +export function emitLocalStackChanged(event: StackChangedEvent): void { + localListeners.forEach((listener) => listener(event)); +} + +export function push(req: StackPushRequest): Promise { + return callStack('push', req as unknown as Record); +} + +export function pop(req: StackPopRequest = {}): Promise { + return callStack('pop', req as Record); +} + +export function popTo(req: StackPopToRequest): Promise { + return callStack('popTo', req as unknown as Record); +} + +export function replace(req: StackReplaceRequest): Promise { + return callStack('replace', req as unknown as Record); +} + +export function reset(req: StackResetRequest): Promise { + return callStack('reset', req as unknown as Record); +} + +export async function getState(): Promise { + const result = await callStack('getState'); + if (result.code !== 1 || !result.state) { + throw new Error(result.msg); + } + return result.state; +} + +export function prefetch(req: StackPrefetchRequest): Promise { + return callStack('prefetch', req as unknown as Record); +} + +export function syncOwnLocation(req: SyncOwnLocationRequest): void { + void callStack('syncOwnLocation', req as unknown as Record); +} + +export function subscribeStackChanges(listener: (event: StackChangedEvent) => void): () => void { + localListeners.add(listener); + + let nativeListener: ((value: unknown) => void) | undefined; + try { + nativeListener = (value: unknown) => { + const event = unwrapEvent(value); + if (event) { + listener(event); + } + }; + pipe.on(STACK_CHANGED_EVENT, nativeListener); + } catch { + // Web and unit-test environments do not expose Lynx GlobalEventEmitter. + } + + return () => { + localListeners.delete(listener); + if (nativeListener) { + try { + pipe.off(STACK_CHANGED_EVENT, nativeListener); + } catch { + // The native runtime may already have been destroyed. + } + } + }; +} + +export const nativeStack: NativeStackProtocol = { + push, + pop, + popTo, + replace, + reset, + getState, + prefetch, + syncOwnLocation, + subscribe: subscribeStackChanges, +}; diff --git a/packages/methods/sparkling-navigation/src/stack/stack.types.ts b/packages/methods/sparkling-navigation/src/stack/stack.types.ts new file mode 100644 index 00000000..560db00b --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/stack.types.ts @@ -0,0 +1,107 @@ +// Copyright 2026 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 StackPresentation = 'push' | 'modal'; + +export interface StackEntry { + id: string; + path: string; + search: Record; + bundle: string; + presentation: StackPresentation; +} + +export interface StackState { + version: number; + entries: StackEntry[]; +} + +export type StackChangeReason = + | 'push' + | 'pop' + | 'replace' + | 'reset' + | 'user-back-gesture' + | 'user-back-button' + | 'system'; + +export interface StackChangedEvent { + state: StackState; + reason: StackChangeReason; + result?: { + forEntryId: string; + /** The container that produced the result, used to correlate concurrent pushes. */ + fromEntryId?: string; + value: unknown; + }; +} + +export interface NavResult { + code: number; + msg: string; + entryId?: string; + state?: StackState; +} + +export interface StackLocationRequest { + path: string; + search?: Record; + /** + * Bridge-only resolved target. sparkling-router derives these fields from + * its manifest; native never needs to parse or own that manifest. + */ + bundle?: string; + scheme?: string; +} + +export interface StackPushRequest extends StackLocationRequest { + presentation?: StackPresentation; + usePrefetched?: boolean; + animated?: boolean; +} + +export interface StackPopRequest { + result?: unknown; + animated?: boolean; +} + +export interface StackPopToRequest { + entryId: string; + animated?: boolean; +} + +export interface StackReplaceRequest extends StackLocationRequest { + presentation?: StackPresentation; + animated?: boolean; +} + +export interface StackResetEntry extends StackLocationRequest { + presentation?: StackPresentation; +} + +export interface StackResetRequest { + entries: StackResetEntry[]; + animated?: boolean; +} + +export interface StackPrefetchRequest extends StackLocationRequest { + presentation?: StackPresentation; +} + +export interface SyncOwnLocationRequest { + path: string; + search: Record; +} + +export interface NativeStackProtocol { + push(req: StackPushRequest): Promise; + pop(req?: StackPopRequest): Promise; + popTo(req: StackPopToRequest): Promise; + replace(req: StackReplaceRequest): Promise; + reset(req: StackResetRequest): Promise; + getState(): Promise; + prefetch(req: StackPrefetchRequest): Promise; + syncOwnLocation(req: SyncOwnLocationRequest): void; + subscribe(listener: (event: StackChangedEvent) => void): () => void; +} diff --git a/packages/methods/sparkling-navigation/src/web/index.ts b/packages/methods/sparkling-navigation/src/web/index.ts index b3e8a2aa..27ee9ef3 100644 --- a/packages/methods/sparkling-navigation/src/web/index.ts +++ b/packages/methods/sparkling-navigation/src/web/index.ts @@ -3,6 +3,15 @@ // LICENSE file in the root directory of this source tree. import { registerWebMethod } from 'sparkling-method/web-registry'; +import { + emitLocalStackChanged, + STACK_CHANGED_EVENT, +} from '../stack/stack'; +import type { + StackChangedEvent, + StackEntry, + StackState, +} from '../stack/stack.types'; /** * How web navigation is actually performed. The default host drives the @@ -13,7 +22,8 @@ import { registerWebMethod } from 'sparkling-method/web-registry'; */ export interface RouterWebHost { open(pageName: string, scheme: string): void; - close(): void; + replace?(pageName: string, scheme: string): void; + close(params?: { containerID?: string; animated?: boolean }): void; } const defaultHost: RouterWebHost = { @@ -25,6 +35,13 @@ const defaultHost: RouterWebHost = { new CustomEvent('sparkling:navigate', { detail: { page: pageName, state } }), ); }, + replace(pageName, scheme) { + const state = { page: pageName, scheme }; + window.history.replaceState(state, '', `?page=${encodeURIComponent(pageName)}`); + window.dispatchEvent( + new CustomEvent('sparkling:navigate', { detail: { page: pageName, state } }), + ); + }, close() { window.history.back(); }, @@ -78,11 +95,159 @@ registerWebMethod('router.open', (params, callback) => { } }); -registerWebMethod('router.close', (_params, callback) => { +registerWebMethod('router.close', (params, callback) => { try { - host.close(); + host.close(params.data as { containerID?: string; animated?: boolean }); callback({ code: 1, msg: 'ok' }); } catch (e) { callback({ code: 0, msg: `Failed to close: ${e}` }); } }); + +let webStackState: StackState = { version: 0, entries: [] }; + +function nextEntryId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `web-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function publishStack( + reason: StackChangedEvent['reason'], + result?: StackChangedEvent['result'], +): void { + webStackState = { + version: webStackState.version + 1, + entries: webStackState.entries.slice(), + }; + const event: StackChangedEvent = { state: webStackState, reason, result }; + emitLocalStackChanged(event); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(STACK_CHANGED_EVENT, { detail: event })); + } +} + +function stackEntry(data: Record): StackEntry { + return { + id: nextEntryId(), + path: String(data.path ?? '/'), + search: (data.search as Record | undefined) ?? {}, + bundle: String(data.bundle ?? parsePageName(String(data.scheme ?? '')) ?? ''), + presentation: data.presentation === 'modal' ? 'modal' : 'push', + }; +} + +registerWebMethod('router.stack', (params, callback) => { + const data = (params.data ?? {}) as Record; + const command = data.command; + + try { + if (command === 'getState') { + callback({ code: 1, msg: 'ok', data: { state: webStackState } }); + return; + } + + if (command === 'push') { + const scheme = String(data.scheme ?? ''); + const pageName = parsePageName(scheme); + if (!pageName) { + callback({ code: 0, msg: 'push requires a resolved scheme' }); + return; + } + const entry = stackEntry(data); + host.open(pageName, scheme); + webStackState.entries.push(entry); + publishStack('push'); + callback({ code: 1, msg: 'ok', data: { entryId: entry.id, state: webStackState } }); + return; + } + + if (command === 'pop') { + const popped = webStackState.entries.pop(); + host.close({ animated: data.animated as boolean | undefined }); + const parent = webStackState.entries[webStackState.entries.length - 1]; + publishStack( + 'pop', + parent && 'result' in data + ? { + forEntryId: parent.id, + fromEntryId: popped?.id, + value: data.result, + } + : undefined, + ); + callback({ code: popped ? 1 : 0, msg: popped ? 'ok' : 'stack is empty', data: { state: webStackState } }); + return; + } + + if (command === 'popTo') { + const index = webStackState.entries.findIndex((entry) => entry.id === data.entryId); + if (index < 0) { + callback({ code: 0, msg: `Unknown entry: ${String(data.entryId)}` }); + return; + } + webStackState.entries.splice(index + 1); + publishStack('pop'); + callback({ code: 1, msg: 'ok', data: { state: webStackState } }); + return; + } + + if (command === 'replace') { + const scheme = String(data.scheme ?? ''); + const pageName = parsePageName(scheme); + if (!pageName) { + callback({ code: 0, msg: 'replace requires a resolved scheme' }); + return; + } + const entry = stackEntry(data); + if (webStackState.entries.length > 0) { + webStackState.entries.splice(-1, 1, entry); + } else { + webStackState.entries.push(entry); + } + (host.replace ?? host.open)(pageName, scheme); + publishStack('replace'); + callback({ code: 1, msg: 'ok', data: { entryId: entry.id, state: webStackState } }); + return; + } + + if (command === 'reset') { + const entries = Array.isArray(data.entries) + ? data.entries.map((entry) => stackEntry(entry as Record)) + : []; + webStackState.entries = entries; + const rawEntries = Array.isArray(data.entries) ? data.entries : []; + const last = rawEntries[rawEntries.length - 1] as Record | undefined; + const scheme = String(last?.scheme ?? ''); + const pageName = scheme ? parsePageName(scheme) : null; + if (pageName) { + (host.replace ?? host.open)(pageName, scheme); + } + publishStack('reset'); + callback({ code: 1, msg: 'ok', data: { entryId: entries[entries.length - 1]?.id, state: webStackState } }); + return; + } + + if (command === 'syncOwnLocation') { + const entry = webStackState.entries.find((item) => item.id === params.containerID) + ?? webStackState.entries[webStackState.entries.length - 1]; + if (entry) { + entry.path = String(data.path ?? entry.path); + entry.search = (data.search as Record | undefined) ?? entry.search; + publishStack('replace'); + } + callback({ code: 1, msg: 'ok', data: { state: webStackState } }); + return; + } + + if (command === 'prefetch') { + callback({ code: 1, msg: 'ok' }); + return; + } + + callback({ code: 0, msg: `Unknown stack command: ${String(command)}` }); + } catch (e) { + callback({ code: 0, msg: `Stack command failed: ${e}` }); + } +}); diff --git a/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingApplication.kt b/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingApplication.kt index 8b5d1709..2710be50 100644 --- a/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingApplication.kt +++ b/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingApplication.kt @@ -17,6 +17,7 @@ import com.tiktok.sparkling.hybridkit.config.SparklingLynxConfig import com.tiktok.sparkling.method.registry.core.SparklingBridgeManager import com.tiktok.sparkling.method.router.close.RouterCloseMethod import com.tiktok.sparkling.method.router.open.RouterOpenMethod +import com.tiktok.sparkling.method.router.stack.RouterStackMethod import com.tiktok.sparkling.method.router.utils.RouterProvider import com.tiktok.sparkling.method.runtime.depend.CommonDependsProvider import com.tiktok.sparkling.method.storage.getItem.StorageGetItemMethod @@ -76,6 +77,7 @@ class SparklingApplication : Application() { private fun initSparklingMethods() { SparklingBridgeManager.registerIDLMethod("router.open", clazz = RouterOpenMethod::class.java) { RouterOpenMethod() } SparklingBridgeManager.registerIDLMethod("router.close", clazz = RouterCloseMethod::class.java) { RouterCloseMethod() } + SparklingBridgeManager.registerIDLMethod("router.stack", clazz = RouterStackMethod::class.java) { RouterStackMethod() } RouterProvider.hostRouterDepend = SparklingHostRouterDepend() SparklingBridgeManager.registerIDLMethod("storage.setItem", clazz = StorageSetItemMethod::class.java) { StorageSetItemMethod() } diff --git a/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingAutolink.kt b/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingAutolink.kt index e07a077a..b2b3215e 100644 --- a/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingAutolink.kt +++ b/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingAutolink.kt @@ -16,7 +16,7 @@ object SparklingAutolink { ), SparklingAutolinkModule( name = "sparkling-navigation", - androidPackage = "com.tiktok.sparkling.methods.router", + androidPackage = "com.tiktok.sparkling.method.router", className = "RouterMethod", ), SparklingAutolinkModule( diff --git a/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingHostRouterDepend.kt b/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingHostRouterDepend.kt index c9dc41e4..95aa0fe6 100644 --- a/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingHostRouterDepend.kt +++ b/packages/playground/android/app/src/main/java/com/tiktok/sparkling/playground/SparklingHostRouterDepend.kt @@ -6,10 +6,15 @@ package com.tiktok.sparkling.playground import android.content.Context import com.tiktok.sparkling.Sparkling import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.SparklingNavigationStack +import com.tiktok.sparkling.SparklingNavigationTarget import com.tiktok.sparkling.hybridkit.service.HybridActivityStackManager import com.tiktok.sparkling.method.registry.core.IBridgeContext import com.tiktok.sparkling.method.registry.core.BridgePlatformType import com.tiktok.sparkling.method.router.utils.IHostRouterDepend +import com.tiktok.sparkling.method.router.utils.RouterStackCommand +import com.tiktok.sparkling.method.router.utils.RouterStackResult +import com.tiktok.sparkling.method.router.utils.RouterStackTarget class SparklingHostRouterDepend : IHostRouterDepend { override fun openScheme( @@ -28,8 +33,9 @@ class SparklingHostRouterDepend : IHostRouterDepend { val k = key?.toString() ?: return@mapNotNull null k to (value?.toString() ?: "") }?.toMap() - context?.let { Sparkling.Companion.build(it, sparklingContext).navigate() } - return true + return context?.let { + Sparkling.Companion.build(it, sparklingContext).navigate() + } ?: false } override fun closeView( @@ -38,12 +44,97 @@ class SparklingHostRouterDepend : IHostRouterDepend { containerID: String?, animated: Boolean?, ): Boolean { + if (!containerID.isNullOrBlank()) { + return SparklingNavigationStack.pop(containerID).success + } + val currentId = bridgeContext?.containerID + if (!currentId.isNullOrBlank() && SparklingNavigationStack.pop(currentId).success) { + return true + } val ownerActivity = bridgeContext?.ownerActivity if (ownerActivity != null) { ownerActivity.finish() + return true } else { - HybridActivityStackManager.getTopActivity()?.finish() + val top = HybridActivityStackManager.getTopActivity() ?: return false + top.finish() + return true } - return true } + + override fun executeStackCommand( + bridgeContext: IBridgeContext?, + command: RouterStackCommand, + context: Context?, + ): RouterStackResult? { + val appContext = context ?: bridgeContext?.context + val response = + when (command.command) { + "getState" -> null + "push" -> { + val target = command.target ?: return RouterStackResult(false, "push requires a target") + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.push( + hostContext, + target.toNative(), + usePrefetched = command.usePrefetched, + sourceEntryId = bridgeContext?.containerID, + ) + } + "pop" -> + SparklingNavigationStack.pop( + bridgeContext?.containerID, + result = command.result, + ) + "popTo" -> { + val entryId = command.entryId ?: return RouterStackResult(false, "popTo requires entryId") + SparklingNavigationStack.popTo(entryId) + } + "replace" -> { + val target = command.target ?: return RouterStackResult(false, "replace requires a target") + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.replace( + hostContext, + bridgeContext?.containerID, + target.toNative(), + ) + } + "reset" -> { + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.reset( + hostContext, + command.entries.map { it.toNative() }, + ) + } + "prefetch" -> { + val target = command.target ?: return RouterStackResult(false, "prefetch requires a target") + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.prefetch(hostContext, target.toNative()) + } + "syncOwnLocation" -> { + val target = command.target ?: return RouterStackResult(false, "syncOwnLocation requires a location") + SparklingNavigationStack.syncOwnLocation( + bridgeContext?.containerID, + target.path, + target.search, + ) + } + else -> return RouterStackResult(false, "Unknown command: ${command.command}") + } + return RouterStackResult( + success = response?.success ?: true, + message = response?.message ?: "ok", + entryId = response?.entryId, + state = SparklingNavigationStack.stateMap(), + ) + } + + private fun RouterStackTarget.toNative(): SparklingNavigationTarget = + SparklingNavigationTarget( + path = path, + search = search, + bundle = bundle, + scheme = scheme, + presentation = presentation, + ) } diff --git a/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift b/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift index 79abd27f..42c234b1 100644 --- a/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift +++ b/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift @@ -8,12 +8,23 @@ import SparklingMethod import Sparkling_Router import UIKit -class RouterServiceImpl: RouterService { +class RouterServiceImpl: RouterService, RouterStackService { func closeContainer(withParams params: Sparkling_Router.CloseMethodParamModel, completion: @escaping SparklingMethod.PipeMethod.CompletionBlock) { - if SPKRouter.close(container: params.context?.pipeContainer) { - completion(.succeeded(), nil) - } else { - completion(.failed(message: "Unable to close the container"), nil) + DispatchQueue.main.async { + let success: Bool + if let containerID = params.containerID, !containerID.isEmpty { + success = SPKRouter.close( + containerID: containerID, + animated: params.animated + ) + } else { + success = SPKRouter.close(container: params.context?.pipeContainer) + } + if success { + completion(.succeeded(), nil) + } else { + completion(.failed(message: "Unable to close the container"), nil) + } } } @@ -34,7 +45,12 @@ class RouterServiceImpl: RouterService { DispatchQueue.main.async { func openWithRouter(completionHandler: ((Bool) -> Void)? = nil) { - if let (_, success) = SPKRouter.open(withURL: urlString, context: context), success { + if let (_, success) = SPKRouter.open( + withURL: urlString, + context: context, + presentation: "push", + animated: params.animated + ), success { completionHandler?(true) completion(.succeeded(), nil) } else { @@ -52,7 +68,7 @@ class RouterServiceImpl: RouterService { } } else { if params.replace == true && params.replaceType == "alwaysCloseBeforeOpen" { - if SPKRouter.close(container: params.context?.pipeContainer) { + if !SPKRouter.close(container: params.context?.pipeContainer) { print("Unable to close the container") } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -61,7 +77,7 @@ class RouterServiceImpl: RouterService { } else if params.replace == true { openWithRouter { success in if params.replaceType == "alwaysCloseAfterOpen" || (params.replaceType == "onlyCloseAfterOpenSucceed" && success) { - if SPKRouter.close(container: params.context?.pipeContainer) { + if !SPKRouter.close(container: params.context?.pipeContainer) { print("Unable to close the container") } } @@ -72,4 +88,156 @@ class RouterServiceImpl: RouterService { } } } + + func performStackCommand( + withParams params: Sparkling_Router.StackMethodParamModel, + completion: @escaping SparklingMethod.PipeMethod.CompletionBlock + ) { + DispatchQueue.main.async { + let stack = SPKNavigationStack.shared + let sourceID = params.context?.pipeContainer?.spk_containerID + let resultModel = StackMethodResultModel() + + func finish(_ result: SPKNavigationResult) { + resultModel.entryId = result.entryId + resultModel.state = stack.stateDictionary as NSDictionary + if result.success { + completion(.succeeded(), resultModel) + } else { + completion(.failed(message: result.message), nil) + } + } + + switch params.command { + case "getState": + resultModel.state = stack.stateDictionary as NSDictionary + completion(.succeeded(), resultModel) + case "push": + guard let target = self.target(from: params) else { + completion(.invalidParameter(message: "push requires scheme and path"), nil) + return + } + let (_, result) = stack.push( + target, + context: SPKContext(), + animated: params.animated, + usePrefetched: params.usePrefetched, + sourceEntryId: sourceID + ) + finish(result) + case "pop": + guard let sourceID = sourceID, !sourceID.isEmpty else { + completion(.invalidParameter(message: "pop requires a source container"), nil) + return + } + finish(stack.pop( + entryId: sourceID, + result: params.result, + animated: params.animated + )) + case "popTo": + guard let entryId = params.entryId, !entryId.isEmpty else { + completion(.invalidParameter(message: "popTo requires entryId"), nil) + return + } + finish(stack.popTo(entryId: entryId, animated: params.animated)) + case "replace": + guard let sourceID = sourceID, !sourceID.isEmpty else { + completion(.invalidParameter(message: "replace requires a source container"), nil) + return + } + guard let target = self.target(from: params) else { + completion(.invalidParameter(message: "replace requires scheme and path"), nil) + return + } + finish(stack.replace( + entryId: sourceID, + target: target, + context: SPKContext(), + animated: params.animated + )) + case "reset": + guard let rawEntries = params.entries as? [[String: Any]], + !rawEntries.isEmpty + else { + completion(.invalidParameter(message: "reset requires entries"), nil) + return + } + let targets = rawEntries.compactMap { self.target(from: $0) } + guard targets.count == rawEntries.count else { + completion(.invalidParameter(message: "reset contains an invalid entry"), nil) + return + } + finish(stack.reset( + targets: targets, + context: SPKContext(), + animated: params.animated + )) + case "prefetch": + guard let target = self.target(from: params) else { + completion(.invalidParameter(message: "prefetch requires scheme and path"), nil) + return + } + finish(stack.prefetch(target, context: SPKContext())) + case "syncOwnLocation": + guard let sourceID = sourceID, !sourceID.isEmpty else { + completion( + .invalidParameter(message: "syncOwnLocation requires a source container"), + nil + ) + return + } + finish(stack.syncOwnLocation( + entryId: sourceID, + path: params.path ?? "/", + search: self.stringDictionary(params.search) + )) + default: + completion( + .invalidParameter(message: "Unknown stack command: \(params.command ?? "")"), + nil + ) + } + } + } + + private func target( + from params: Sparkling_Router.StackMethodParamModel + ) -> SPKNavigationTarget? { + guard let scheme = params.scheme, !scheme.isEmpty, + let path = params.path, !path.isEmpty + else { + return nil + } + return SPKNavigationTarget( + path: path, + search: stringDictionary(params.search), + bundle: params.bundle ?? "", + scheme: scheme, + presentation: params.presentation ?? "push" + ) + } + + private func target(from dictionary: [String: Any]) -> SPKNavigationTarget? { + guard let scheme = dictionary["scheme"] as? String, !scheme.isEmpty, + let path = dictionary["path"] as? String, !path.isEmpty + else { + return nil + } + return SPKNavigationTarget( + path: path, + search: stringDictionary(dictionary["search"] as? NSDictionary), + bundle: dictionary["bundle"] as? String ?? "", + scheme: scheme, + presentation: dictionary["presentation"] as? String ?? "push" + ) + } + + private func stringDictionary(_ dictionary: NSDictionary?) -> [String: String] { + var result: [String: String] = [:] + dictionary?.forEach { key, value in + result[String(describing: key)] = String(describing: value) + } + return result + } } diff --git a/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift b/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift index 82e83a37..d891cbd1 100644 --- a/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift +++ b/packages/playground/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift @@ -16,6 +16,9 @@ enum SPKServiceRegister { DIProviderRegistry.provider.pipeShared().register(RouterService.self) { RouterServiceImpl() } + DIProviderRegistry.provider.pipeShared().register(RouterStackService.self) { + RouterServiceImpl() + } DIProviderRegistry.provider.pipeShared().register(StorageService.self) { StorageServiceImpl() diff --git a/packages/sparkling-router-plugin/index.ts b/packages/sparkling-router-plugin/index.ts new file mode 100644 index 00000000..a18c778e --- /dev/null +++ b/packages/sparkling-router-plugin/index.ts @@ -0,0 +1,8 @@ +// Copyright 2026 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 * from './src/generator'; +export * from './src/plugin'; +export * from './src/scanner'; +export type * from './src/types'; diff --git a/packages/sparkling-router-plugin/jest.config.ts b/packages/sparkling-router-plugin/jest.config.ts new file mode 100644 index 00000000..b695af40 --- /dev/null +++ b/packages/sparkling-router-plugin/jest.config.ts @@ -0,0 +1,25 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleNameMapper: { + '^prettier$': '/src/__tests__/mocks/prettier.js', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/__tests__/**', + ], + coverageThreshold: { + global: { + statements: 80, + branches: 70, + functions: 80, + lines: 80, + }, + }, +}; + +export default config; diff --git a/packages/sparkling-router-plugin/package.json b/packages/sparkling-router-plugin/package.json new file mode 100644 index 00000000..e9b3ce33 --- /dev/null +++ b/packages/sparkling-router-plugin/package.json @@ -0,0 +1,53 @@ +{ + "name": "sparkling-router-plugin", + "version": "2.1.0-rc.12", + "description": "File-route code generation and multi-entry builds for Sparkling Router", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-router-plugin" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "index.ts", + "src" + ], + "scripts": { + "build": "tsc", + "test": "jest", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "@tanstack/router-generator": "1.167.21", + "@tanstack/virtual-file-routes": "1.162.0" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^26.1.1", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "@lynx-js/rspeedy": ">=0.13.0", + "@rsbuild/core": ">=1.7.0" + }, + "peerDependenciesMeta": { + "@lynx-js/rspeedy": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + } + }, + "license": "Apache-2.0" +} diff --git a/packages/sparkling-router-plugin/src/__tests__/generator.test.ts b/packages/sparkling-router-plugin/src/__tests__/generator.test.ts new file mode 100644 index 00000000..e8c0c9b4 --- /dev/null +++ b/packages/sparkling-router-plugin/src/__tests__/generator.test.ts @@ -0,0 +1,146 @@ +import { + mkdtemp, + mkdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { mergeRsbuildConfig } from '@rsbuild/core'; +import { generateSparklingRoutes } from '../generator'; +import { pluginSparklingRouter } from '../plugin'; +import { scanSparklingRoutes } from '../scanner'; + +let root: string; +let routesDirectory: string; + +async function route(path: string, source: string): Promise { + const target = join(routesDirectory, path); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, source); +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'sparkling-router-')); + routesDirectory = join(root, 'src/routes'); + await mkdir(routesDirectory, { recursive: true }); + await route('__root.tsx', ` +import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute() +`); + await route('index.tsx', ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ component: () => null }) +`); + await route('feed/_container.tsx', ` +export default { + presentation: 'push', + containerOptions: { hide_loading: '1', "nav_bar": "dark" }, +} +`); + await route('feed/index.tsx', ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/feed/')({ component: () => null }) +`); + await route('feed/$postId.tsx', ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/feed/$postId')({ component: () => null }) +`); + await route('settings/_container.modal.tsx', `export default { presentation: 'modal' }`); + await route('settings/index.tsx', ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/settings/')({ component: () => null }) +`); + await route('settings/-utils.ts', `export const helper = true`); + await route('settings/view.test.tsx', `throw new Error('not a route')`); + await route('user.$id.tsx', ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/user/$id')({ component: () => null }) +`); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('scanSparklingRoutes', () => { + it('partitions marked subtrees and unmarked top-level files', async () => { + const result = await scanSparklingRoutes(routesDirectory); + + expect(result.containers.map((container) => container.bundle)).toEqual([ + 'feed.lynx.bundle', + 'index.lynx.bundle', + 'settings.lynx.bundle', + 'user-param-id.lynx.bundle', + ]); + expect(result.containers[0]).toMatchObject({ + presentation: 'push', + containerOptions: { hide_loading: '1', nav_bar: 'dark' }, + }); + expect(result.containers[0].routes.map((item) => item.routePath)).toEqual([ + '/feed/$postId', + '/feed', + ]); + expect(result.containers[2].presentation).toBe('modal'); + expect(result.containers[2].routes.map((item) => item.routePath)).toEqual([ + '/settings', + ]); + }); +}); + +describe('generateSparklingRoutes', () => { + it('uses TanStack generator to emit isolated trees, entries, and a manifest', async () => { + const result = await generateSparklingRoutes(root); + + expect(Object.keys(result.entries)).toEqual(['feed', 'index', 'settings', 'user-param-id']); + expect(result.manifest.containers[0].routes).toEqual([ + { path: '/feed' }, + { path: '/feed/$postId' }, + ]); + + const feedTree = await readFile( + join(root, 'src/.sparkling-router/feed/routeTree.gen.ts'), + 'utf8', + ); + expect(feedTree).toContain('feed/$postId'); + expect(feedTree).not.toContain('settings/index'); + + const entry = await readFile(result.entries.feed, 'utf8'); + expect(entry).toContain("containerBundle: 'feed.lynx.bundle'"); + expect(entry).toContain('createSparklingRouter'); + }); +}); + +describe('pluginSparklingRouter', () => { + it('injects generated entries and the ReactLynx compat alias', async () => { + const plugin = pluginSparklingRouter(); + let modify: (( + config: Record, + utils: { mergeRsbuildConfig: typeof mergeRsbuildConfig }, + ) => Record | void) | undefined; + + await plugin.setup({ + context: { rootPath: root }, + modifyRsbuildConfig(callback) { + modify = callback as typeof modify; + }, + } as never); + + const config: { + source?: { + entry?: Record; + }; + resolve?: { alias?: Record }; + } = {}; + const modified = modify?.( + config as Record, + { mergeRsbuildConfig }, + ) as typeof config | undefined; + expect(modified?.source?.entry?.feed).toContain('entry.tsx'); + expect(modified?.resolve?.alias?.['react$']).toBe('@lynx-js/react/compat'); + expect(modified?.resolve?.alias?.['react-dom$']).toBe( + 'sparkling-router/react-dom-shim', + ); + }); +}); diff --git a/packages/sparkling-router-plugin/src/__tests__/mocks/prettier.js b/packages/sparkling-router-plugin/src/__tests__/mocks/prettier.js new file mode 100644 index 00000000..4461f61f --- /dev/null +++ b/packages/sparkling-router-plugin/src/__tests__/mocks/prettier.js @@ -0,0 +1,4 @@ +module.exports = { + format: async (source) => source, + resolveConfig: async () => null, +}; diff --git a/packages/sparkling-router-plugin/src/generator.ts b/packages/sparkling-router-plugin/src/generator.ts new file mode 100644 index 00000000..7d5474ad --- /dev/null +++ b/packages/sparkling-router-plugin/src/generator.ts @@ -0,0 +1,235 @@ +// Copyright 2026 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 { createHash } from 'node:crypto'; +import { + mkdir, + rm, + writeFile, +} from 'node:fs/promises'; +import { + dirname, + join, + relative, + sep, +} from 'node:path'; +import { + Generator, + getConfig, +} from '@tanstack/router-generator'; +import type { + VirtualRootRoute, + VirtualRouteNode, +} from '@tanstack/virtual-file-routes'; +import { scanSparklingRoutes } from './scanner'; +import type { + GeneratedSparklingRoutes, + ScannedContainer, + SparklingRouterPluginOptions, +} from './types'; + +interface RouteTreeNode { + segment: string; + file?: string; + indexFile?: string; + layoutFile?: string; + children: Map; +} + +function portable(path: string): string { + return path.split(sep).join('/'); +} + +function createNode(segment: string): RouteTreeNode { + return { segment, children: new Map() }; +} + +function insertRoute( + root: RouteTreeNode, + segments: string[], +): RouteTreeNode { + let current = root; + segments.forEach((segment) => { + let child = current.children.get(segment); + if (!child) { + child = createNode(segment); + current.children.set(segment, child); + } + current = child; + }); + return current; +} + +function buildVirtualChildren( + node: RouteTreeNode, + parentId: string, +): VirtualRouteNode[] { + const children = [...node.children.values()] + .sort((left, right) => left.segment.localeCompare(right.segment)) + .map((child) => buildVirtualRoute(child, parentId)); + if (node.indexFile) { + children.unshift({ type: 'index', file: node.indexFile }); + } + if (node.layoutFile) { + return [{ + type: 'layout', + id: `${parentId || 'root'}-layout`, + file: node.layoutFile, + children, + }]; + } + return children; +} + +function buildVirtualRoute( + node: RouteTreeNode, + parentId: string, +): VirtualRouteNode { + const id = `${parentId}/${node.segment}`; + const children = buildVirtualChildren(node, id); + return { + type: 'route', + path: node.segment, + file: node.file, + children: children.length > 0 ? children : undefined, + }; +} + +function virtualConfig( + container: ScannedContainer, + rootRouteFile: string, +): VirtualRootRoute { + const root = createNode(''); + container.routes.forEach((route) => { + const node = insertRoute(root, route.routeSegments); + if (route.kind === 'index') { + node.indexFile = route.relativePath; + } else if (route.kind === 'layout') { + node.layoutFile = route.relativePath; + } else { + node.file = route.relativePath; + } + }); + + return { + type: 'root', + file: rootRouteFile, + children: buildVirtualChildren(root, ''), + }; +} + +function entryName(bundle: string): string { + return bundle.replace(/\.lynx\.bundle$/, ''); +} + +function routeTreeImport(fromDirectory: string, routeTreePath: string): string { + let path = portable(relative(fromDirectory, routeTreePath)) + .replace(/\.(?:ts|tsx)$/, '.js'); + if (!path.startsWith('.')) { + path = `./${path}`; + } + return path; +} + +function entrySource( + routeTreePath: string, + manifestPath: string, + entryPath: string, + bundle: string, +): string { + const directory = dirname(entryPath); + const routeTree = routeTreeImport(directory, routeTreePath); + const manifest = routeTreeImport(directory, manifestPath).replace(/\.json\.js$/, '.json'); + return `// Generated by sparkling-router-plugin. Do not edit. +import 'url-search-params-polyfill' +import { root } from '@lynx-js/react' +import { RouterProvider } from '@tanstack/react-router' +import { createSparklingRouter, type RouteManifest } from 'sparkling-router' +import manifestJSON from '${manifest}' +import { routeTree } from '${routeTree}' + +const runtime = createSparklingRouter({ + routeTree, + manifest: manifestJSON as RouteManifest, + containerBundle: '${bundle}', +}) + +root.render() + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept() + import.meta.webpackHot.dispose(() => runtime.destroy()) +} +`; +} + +function manifestVersion( + manifest: Omit, +): string { + return createHash('sha256') + .update(JSON.stringify(manifest)) + .digest('hex') + .slice(0, 12); +} + +export async function generateSparklingRoutes( + root: string, + options: SparklingRouterPluginOptions = {}, +): Promise { + const routesDirectory = join(root, options.routesDirectory ?? 'src/routes'); + const generatedDirectory = join( + root, + options.generatedDirectory ?? 'src/.sparkling-router', + ); + const scanned = await scanSparklingRoutes(routesDirectory); + await rm(generatedDirectory, { recursive: true, force: true }); + await mkdir(generatedDirectory, { recursive: true }); + + const manifestWithoutVersion = { + scheme: { base: options.schemeBase ?? 'hybrid://lynxview_page' }, + containers: scanned.containers.map((container) => ({ + bundle: container.bundle, + presentation: container.presentation, + routes: [...new Set( + container.routes + .filter((route) => route.kind !== 'layout') + .map((route) => route.routePath), + )].sort().map((path) => ({ path })), + ...(container.containerOptions + ? { containerOptions: container.containerOptions } + : {}), + })), + }; + const manifest = { + version: options.manifestVersion ?? manifestVersion(manifestWithoutVersion), + ...manifestWithoutVersion, + }; + const manifestPath = join(generatedDirectory, 'manifest.json'); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const entries: Record = {}; + for (const container of scanned.containers) { + const outputDirectory = join(generatedDirectory, entryName(container.bundle)); + const routeTreePath = join(outputDirectory, 'routeTree.gen.ts'); + const entryPath = join(outputDirectory, 'entry.tsx'); + await mkdir(outputDirectory, { recursive: true }); + + const config = getConfig({ + target: 'react', + routesDirectory, + generatedRouteTree: routeTreePath, + virtualRouteConfig: virtualConfig(container, scanned.rootRouteFile), + disableLogging: options.disableLogging ?? true, + enableRouteTreeFormatting: false, + }, root); + await new Generator({ config, root }).run(); + await writeFile( + entryPath, + entrySource(routeTreePath, manifestPath, entryPath, container.bundle), + ); + entries[entryName(container.bundle)] = entryPath; + } + + return { entries, manifestPath, manifest }; +} diff --git a/packages/sparkling-router-plugin/src/plugin.ts b/packages/sparkling-router-plugin/src/plugin.ts new file mode 100644 index 00000000..eba4d18d --- /dev/null +++ b/packages/sparkling-router-plugin/src/plugin.ts @@ -0,0 +1,36 @@ +// Copyright 2026 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 type { RsbuildPlugin } from '@rsbuild/core'; +import { generateSparklingRoutes } from './generator'; +import type { SparklingRouterPluginOptions } from './types'; + +export type SparklingRouterRsbuildPlugin = RsbuildPlugin; + +export function pluginSparklingRouter( + options: SparklingRouterPluginOptions = {}, +): SparklingRouterRsbuildPlugin { + return { + name: 'sparkling-router-plugin', + enforce: 'pre', + async setup(api) { + const generated = await generateSparklingRoutes(api.context.rootPath, options); + api.modifyRsbuildConfig((config, { mergeRsbuildConfig }) => { + return mergeRsbuildConfig(config, { + source: { + entry: generated.entries, + }, + resolve: { + alias: { + 'react$': '@lynx-js/react/compat', + 'react/jsx-runtime$': '@lynx-js/react/jsx-runtime', + 'react/jsx-dev-runtime$': '@lynx-js/react/jsx-dev-runtime', + 'react-dom$': 'sparkling-router/react-dom-shim', + }, + }, + }); + }); + }, + }; +} diff --git a/packages/sparkling-router-plugin/src/scanner.ts b/packages/sparkling-router-plugin/src/scanner.ts new file mode 100644 index 00000000..b55dd3ec --- /dev/null +++ b/packages/sparkling-router-plugin/src/scanner.ts @@ -0,0 +1,272 @@ +// Copyright 2026 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 { + readdir, + readFile, +} from 'node:fs/promises'; +import { + basename, + dirname, + extname, + join, + relative, + sep, +} from 'node:path'; +import type { + Presentation, + ScanResult, + ScannedContainer, + ScannedRoute, +} from './types'; + +const ROUTE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']); +const ROUTE_PIECE_SUFFIXES = new Set([ + 'lazy', + 'loader', + 'component', + 'pendingComponent', + 'errorComponent', + 'notFoundComponent', +]); + +function portable(path: string): string { + return path.split(sep).join('/'); +} + +async function routeFiles(directory: string): Promise { + const result: string[] = []; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + if ( + entry.name === '.sparkling-router' + || entry.name === 'node_modules' + || entry.name.startsWith('.') + ) { + continue; + } + const path = join(directory, entry.name); + if (entry.isDirectory()) { + result.push(...await routeFiles(path)); + } else if ( + ROUTE_EXTENSIONS.has(extname(entry.name)) + && !entry.name.startsWith('-') + && !/\.d\.[cm]?[jt]sx?$/.test(entry.name) + && !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name) + ) { + result.push(path); + } + } + return result.sort(); +} + +function withoutExtension(path: string): string { + return path.slice(0, -extname(path).length); +} + +function routeTokens(relativePath: string): string[] { + return withoutExtension(relativePath) + .split('/') + .flatMap((segment) => segment.split('.')); +} + +function isContainerBoundary(relativePath: string): boolean { + return /^_container(?:\.modal)?$/.test( + basename(withoutExtension(relativePath)), + ); +} + +function isPathless(token: string): boolean { + return ( + (token.startsWith('(') && token.endsWith(')')) + || token.startsWith('_') + ); +} + +function toRoute(relativePath: string): ScannedRoute | null { + if (isContainerBoundary(relativePath)) { + return null; + } + const tokens = routeTokens(relativePath); + const finalToken = tokens[tokens.length - 1]; + if (finalToken === '__root') { + return null; + } + if (ROUTE_PIECE_SUFFIXES.has(finalToken)) { + return null; + } + + const isIndex = finalToken === 'index'; + const isLayout = finalToken === '_layout'; + const pathTokens = (isIndex || isLayout ? tokens.slice(0, -1) : tokens) + .filter((token) => !isPathless(token)); + const routePath = pathTokens.length > 0 ? `/${pathTokens.join('/')}` : '/'; + + return { + absolutePath: '', + relativePath, + routePath, + routeSegments: pathTokens, + kind: isLayout ? 'layout' : isIndex ? 'index' : 'route', + }; +} + +function slug(value: string): string { + const normalized = value + .replace(/\$/g, 'param-') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); + return normalized || 'index'; +} + +function nearestBoundary( + routeRelativePath: string, + boundaries: Map, +): string | undefined { + let directory = portable(dirname(routeRelativePath)); + if (directory === '.') { + directory = ''; + } + while (true) { + const boundary = boundaries.get(directory); + if (boundary) { + return boundary; + } + if (!directory) { + return undefined; + } + const parent = portable(dirname(directory)); + directory = parent === '.' ? '' : parent; + } +} + +async function readBoundaryOptions( + path: string, +): Promise<{ presentation: Presentation; containerOptions?: Record }> { + const source = await readFile(path, 'utf8'); + const presentationMatch = source.match(/presentation\s*:\s*['"](push|modal)['"]/); + const presentation: Presentation = ( + basename(path).includes('.modal.') + || presentationMatch?.[1] === 'modal' + ) ? 'modal' : 'push'; + const optionsBlock = source.match(/containerOptions\s*:\s*\{([\s\S]*?)\}/)?.[1]; + const containerOptions: Record = {}; + + if (optionsBlock) { + const pairPattern = /(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$-]*))\s*:\s*['"]([^'"]*)['"]/g; + let match: RegExpExecArray | null; + while ((match = pairPattern.exec(optionsBlock)) !== null) { + containerOptions[match[1] ?? match[2]] = match[3]; + } + } + + return { + presentation, + containerOptions: Object.keys(containerOptions).length > 0 + ? containerOptions + : undefined, + }; +} + +function uniqueBundle(base: string, used: Set): string { + let candidate = `${slug(base)}.lynx.bundle`; + let suffix = 2; + while (used.has(candidate)) { + candidate = `${slug(base)}-${suffix}.lynx.bundle`; + suffix += 1; + } + used.add(candidate); + return candidate; +} + +export async function scanSparklingRoutes( + routesDirectory: string, +): Promise { + const files = await routeFiles(routesDirectory); + const relativeFiles = files.map((path) => portable(relative(routesDirectory, path))); + const rootRouteFile = relativeFiles.find((path) => withoutExtension(path) === '__root'); + if (!rootRouteFile) { + throw new Error(`Sparkling Router requires ${join(routesDirectory, '__root.tsx')}`); + } + + const boundaries = new Map(); + relativeFiles.forEach((path, index) => { + if (isContainerBoundary(path)) { + const directory = portable(dirname(path)); + boundaries.set(directory === '.' ? '' : directory, files[index]); + } + }); + + const usedBundles = new Set(); + const boundaryContainers = new Map(); + for (const [directory, boundaryFile] of boundaries) { + const options = await readBoundaryOptions(boundaryFile); + boundaryContainers.set(boundaryFile, { + id: directory || 'root', + bundle: uniqueBundle(directory || 'root', usedBundles), + presentation: options.presentation, + containerOptions: options.containerOptions, + boundaryFile, + routes: [], + }); + } + + const containers: ScannedContainer[] = [...boundaryContainers.values()]; + const layouts: ScannedRoute[] = []; + + relativeFiles.forEach((relativePath, index) => { + const route = toRoute(relativePath); + if (!route) { + return; + } + route.absolutePath = files[index]; + if (route.kind === 'layout') { + layouts.push(route); + return; + } + + const boundaryFile = nearestBoundary(relativePath, boundaries); + if (boundaryFile) { + boundaryContainers.get(boundaryFile)?.routes.push(route); + return; + } + + const id = route.routeSegments.join('-') || 'index'; + containers.push({ + id, + bundle: uniqueBundle(id, usedBundles), + presentation: 'push', + routes: [route], + }); + }); + + layouts.forEach((layout) => { + const boundaryFile = nearestBoundary(layout.relativePath, boundaries); + if (boundaryFile) { + boundaryContainers.get(boundaryFile)?.routes.push(layout); + return; + } + const layoutDirectory = portable(dirname(layout.relativePath)); + containers.forEach((container) => { + if (container.routes.some((route) => ( + layoutDirectory === '.' + || route.relativePath.startsWith(`${layoutDirectory}/`) + ))) { + container.routes.push(layout); + } + }); + }); + + containers.forEach((container) => { + container.routes.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + }); + + return { + rootRouteFile, + containers: containers + .filter((container) => container.routes.some((route) => route.kind !== 'layout')) + .sort((left, right) => left.id.localeCompare(right.id)), + }; +} diff --git a/packages/sparkling-router-plugin/src/types.ts b/packages/sparkling-router-plugin/src/types.ts new file mode 100644 index 00000000..7939c04a --- /dev/null +++ b/packages/sparkling-router-plugin/src/types.ts @@ -0,0 +1,50 @@ +// Copyright 2026 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 Presentation = 'push' | 'modal'; + +export interface ScannedRoute { + absolutePath: string; + relativePath: string; + routePath: string; + routeSegments: string[]; + kind: 'route' | 'index' | 'layout'; +} + +export interface ScannedContainer { + id: string; + bundle: string; + presentation: Presentation; + containerOptions?: Record; + boundaryFile?: string; + routes: ScannedRoute[]; +} + +export interface ScanResult { + rootRouteFile: string; + containers: ScannedContainer[]; +} + +export interface SparklingRouterPluginOptions { + routesDirectory?: string; + generatedDirectory?: string; + schemeBase?: string; + manifestVersion?: string; + disableLogging?: boolean; +} + +export interface GeneratedSparklingRoutes { + entries: Record; + manifestPath: string; + manifest: { + version: string; + scheme: { base: string }; + containers: Array<{ + bundle: string; + presentation: Presentation; + routes: Array<{ path: string }>; + containerOptions?: Record; + }>; + }; +} diff --git a/packages/sparkling-router-plugin/tsconfig.json b/packages/sparkling-router-plugin/tsconfig.json new file mode 100644 index 00000000..785a3315 --- /dev/null +++ b/packages/sparkling-router-plugin/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "module": "CommonJS", + "noEmit": false + }, + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/sparkling-router/index.ts b/packages/sparkling-router/index.ts new file mode 100644 index 00000000..31a84ac6 --- /dev/null +++ b/packages/sparkling-router/index.ts @@ -0,0 +1,12 @@ +// Copyright 2026 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 'url-search-params-polyfill'; + +export * from './src/composite-history'; +export * from './src/container'; +export * from './src/global-stack-mirror'; +export * from './src/manifest'; +export * from './src/router'; +export * from './src/runtime-context'; diff --git a/packages/sparkling-router/jest.config.ts b/packages/sparkling-router/jest.config.ts new file mode 100644 index 00000000..35de2777 --- /dev/null +++ b/packages/sparkling-router/jest.config.ts @@ -0,0 +1,22 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/__tests__/**', + ], + coverageThreshold: { + global: { + statements: 85, + branches: 75, + functions: 85, + lines: 85, + }, + }, +}; + +export default config; diff --git a/packages/sparkling-router/package.json b/packages/sparkling-router/package.json new file mode 100644 index 00000000..9a8a5ad6 --- /dev/null +++ b/packages/sparkling-router/package.json @@ -0,0 +1,49 @@ +{ + "name": "sparkling-router", + "version": "2.1.0-rc.12", + "description": "URL-first declarative routing across Sparkling native containers", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-router" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./react-dom-shim": { + "types": "./dist/src/react-dom-shim.d.ts", + "default": "./dist/src/react-dom-shim.js" + } + }, + "files": [ + "dist", + "index.ts", + "src" + ], + "scripts": { + "build": "tsc", + "test": "jest", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-router": "1.170.18", + "sparkling-navigation": "workspace:*", + "url-search-params-polyfill": "8.2.5" + }, + "peerDependencies": { + "@lynx-js/react": ">=0.116.0" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.8.3" + }, + "license": "Apache-2.0" +} diff --git a/packages/sparkling-router/src/__tests__/router.test.ts b/packages/sparkling-router/src/__tests__/router.test.ts new file mode 100644 index 00000000..d4a87261 --- /dev/null +++ b/packages/sparkling-router/src/__tests__/router.test.ts @@ -0,0 +1,250 @@ +import type { + NativeStackProtocol, + NavResult, + StackChangedEvent, + StackState, +} from 'sparkling-navigation'; +import { + createRootRoute, + createRoute, +} from '@tanstack/react-router'; +import { CompositeHistory } from '../composite-history'; +import { GlobalStackMirror } from '../global-stack-mirror'; +import { + buildStackLocation, + readInitialHref, + resolveRoute, + type RouteManifest, +} from '../manifest'; +import { createSparklingRouter } from '../router'; + +const manifest: RouteManifest = { + version: 'test', + scheme: { base: 'hybrid://lynxview_page' }, + containers: [ + { + bundle: 'home.lynx.bundle', + presentation: 'push', + routes: [{ path: '/' }], + }, + { + bundle: 'feed.lynx.bundle', + presentation: 'push', + routes: [{ path: '/feed' }, { path: '/feed/$postId' }], + containerOptions: { hide_loading: '1' }, + }, + { + bundle: 'settings.lynx.bundle', + presentation: 'modal', + routes: [{ path: '/settings' }], + }, + ], +}; + +class FakeTransport implements NativeStackProtocol { + state: StackState = { + version: 1, + entries: [{ + id: 'feed-entry', + path: '/feed', + search: {}, + bundle: 'feed.lynx.bundle', + presentation: 'push', + }], + }; + + listeners = new Set<(event: StackChangedEvent) => void>(); + pushes: unknown[] = []; + replaces: unknown[] = []; + pops = 0; + syncs: unknown[] = []; + + async push(req: unknown): Promise { + this.pushes.push(req); + return { code: 1, msg: 'ok', entryId: 'new-entry' }; + } + + async pop(): Promise { + this.pops += 1; + return { code: 1, msg: 'ok' }; + } + + async popTo(): Promise { + return { code: 1, msg: 'ok' }; + } + + async replace(req: unknown): Promise { + this.replaces.push(req); + return { code: 1, msg: 'ok' }; + } + + async reset(): Promise { + return { code: 1, msg: 'ok' }; + } + + async getState(): Promise { + return this.state; + } + + async prefetch(): Promise { + return { code: 1, msg: 'ok' }; + } + + syncOwnLocation(req: unknown): void { + this.syncs.push(req); + } + + subscribe(listener: (event: StackChangedEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(event: StackChangedEvent): void { + this.state = event.state; + this.listeners.forEach((listener) => listener(event)); + } +} + +describe('route manifest', () => { + it('resolves static and parameterized routes to containers', () => { + expect(resolveRoute(manifest, '/feed')).toEqual({ + container: manifest.containers[1], + path: '/feed', + }); + expect(resolveRoute(manifest, '/feed/42')?.container.bundle).toBe('feed.lynx.bundle'); + expect(resolveRoute(manifest, '/missing')).toBeNull(); + }); + + it('builds a native-loadable scheme without losing typed search', () => { + const target = buildStackLocation(manifest, '/feed/42', { sort: 'new' }); + const url = new URL(target.scheme!); + + expect(target.bundle).toBe('feed.lynx.bundle'); + expect(url.searchParams.get('__path')).toBe('/feed/42'); + expect(url.searchParams.get('sort')).toBe('new'); + expect(url.searchParams.get('hide_loading')).toBe('1'); + }); + + it('falls back to the first owned route outside Lynx', () => { + expect(readInitialHref(manifest, 'settings.lynx.bundle')).toBe('/settings'); + }); +}); + +describe('GlobalStackMirror', () => { + it('ignores out-of-order native snapshots', async () => { + const transport = new FakeTransport(); + const mirror = new GlobalStackMirror(transport); + await mirror.start(); + + transport.emit({ state: { version: 3, entries: [] }, reason: 'reset' }); + transport.emit({ state: { version: 2, entries: transport.state.entries }, reason: 'push' }); + + expect(mirror.state.version).toBe(3); + mirror.destroy(); + }); +}); + +describe('CompositeHistory', () => { + function setup() { + const transport = new FakeTransport(); + const mirror = new GlobalStackMirror(transport); + const history = new CompositeHistory({ + manifest, + containerBundle: 'feed.lynx.bundle', + containerEntryId: 'feed-entry', + initialHref: '/feed', + transport, + stackMirror: mirror, + }); + return { history, mirror, transport }; + } + + it('keeps navigation in the same container in memory', () => { + const { history, mirror, transport } = setup(); + history.push('/feed/42?sort=new'); + + expect(history.location.href).toBe('/feed/42?sort=new'); + expect(transport.pushes).toHaveLength(0); + expect(transport.syncs).toEqual([{ path: '/feed/42', search: { sort: 'new' } }]); + history.destroy(); + mirror.destroy(); + }); + + it('translates cross-container navigation into native commands', () => { + const { history, mirror, transport } = setup(); + history.push('/settings'); + + expect(transport.pushes).toHaveLength(1); + expect(transport.pushes[0]).toMatchObject({ + path: '/settings', + bundle: 'settings.lynx.bundle', + presentation: 'modal', + }); + history.destroy(); + mirror.destroy(); + }); + + it('settles TanStack navigation after native owns a hard transition', async () => { + const { history, mirror, transport } = setup(); + const subscriber = jest.fn(); + history.subscribe(subscriber); + + history.push('/settings'); + await Promise.resolve(); + await Promise.resolve(); + + expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ + location: expect.objectContaining({ href: '/feed' }), + action: { type: 'PUSH' }, + })); + history.destroy(); + mirror.destroy(); + }); + + it('hands back to native only after memory history reaches its root', () => { + const { history, mirror, transport } = setup(); + history.push('/feed/42'); + history.back(); + history.back(); + + expect(transport.pops).toBe(1); + history.destroy(); + mirror.destroy(); + }); +}); + +describe('createSparklingRouter', () => { + it('correlates returned values with the child entry ID', async () => { + const transport = new FakeTransport(); + const rootRoute = createRootRoute(); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }); + const runtime = createSparklingRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + manifest, + containerBundle: 'feed.lynx.bundle', + initialHref: '/', + transport, + }); + + const result = runtime.pushWithResult('/settings'); + await Promise.resolve(); + transport.emit({ + state: { + version: 2, + entries: transport.state.entries, + }, + reason: 'pop', + result: { + forEntryId: 'feed-entry', + fromEntryId: 'new-entry', + value: { saved: true }, + }, + }); + + await expect(result).resolves.toEqual({ saved: true }); + runtime.destroy(); + }); +}); diff --git a/packages/sparkling-router/src/composite-history.ts b/packages/sparkling-router/src/composite-history.ts new file mode 100644 index 00000000..61f49518 --- /dev/null +++ b/packages/sparkling-router/src/composite-history.ts @@ -0,0 +1,214 @@ +// Copyright 2026 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 { + createMemoryHistory, + type NavigateOptions, + type NavigationBlocker, + type RouterHistory, +} from '@tanstack/history'; +import type { NativeStackProtocol } from 'sparkling-navigation'; +import type { GlobalStackMirror } from './global-stack-mirror'; +import { + buildStackLocation, + locationHref, + resolveRoute, + searchRecord, + type RouteManifest, +} from './manifest'; + +export interface CompositeHistoryOptions { + manifest: RouteManifest; + containerBundle: string; + containerEntryId?: string; + initialHref: string; + transport: NativeStackProtocol; + stackMirror: GlobalStackMirror; + onHardNavigationError?: (error: Error) => void; +} + +function splitHref(href: string): { + pathname: string; + search: Record; +} { + const url = new URL(href, 'sparkling://router'); + return { + pathname: url.pathname, + search: searchRecord(url.search), + }; +} + +export class CompositeHistory implements RouterHistory { + private readonly memory: RouterHistory; + private readonly stopMirror: () => void; + private converging = false; + private destroyed = false; + + constructor(private readonly options: CompositeHistoryOptions) { + this.memory = createMemoryHistory({ initialEntries: [options.initialHref] }); + this.memory.subscribe(() => { + if (this.converging) { + return; + } + const current = splitHref(this.memory.location.href); + options.transport.syncOwnLocation({ + path: current.pathname, + search: current.search, + }); + }); + this.stopMirror = options.stackMirror.subscribe((state) => { + const ownEntry = options.containerEntryId + ? state.entries.find((entry) => entry.id === options.containerEntryId) + : state.entries.find((entry) => entry.id === this.currentEntryId) + ?? state.entries.find((entry) => entry.bundle === options.containerBundle); + if (!ownEntry) { + return; + } + const href = locationHref(ownEntry.path, ownEntry.search); + if (href !== this.memory.location.href) { + this.converging = true; + this.memory.replace(href, undefined, { ignoreBlocker: true }); + this.converging = false; + } + }); + } + + get location(): RouterHistory['location'] { + return this.memory.location; + } + + get length(): number { + return this.memory.length; + } + + get subscribers(): RouterHistory['subscribers'] { + return this.memory.subscribers; + } + + get currentEntryId(): string | undefined { + if (this.options.containerEntryId) { + return this.options.containerEntryId; + } + const entries = this.options.stackMirror.state.entries; + for (let index = entries.length - 1; index >= 0; index -= 1) { + if (entries[index].bundle === this.options.containerBundle) { + return entries[index].id; + } + } + return undefined; + } + + subscribe: RouterHistory['subscribe'] = (callback) => this.memory.subscribe(callback); + + push = (href: string, state?: unknown, navigateOptions?: NavigateOptions): void => { + const target = splitHref(href); + const resolved = resolveRoute(this.options.manifest, target.pathname); + if (!resolved) { + throw new Error(`No route matches "${target.pathname}"`); + } + if (resolved.container.bundle === this.options.containerBundle) { + this.memory.push(href, state, navigateOptions); + return; + } + const request = buildStackLocation( + this.options.manifest, + target.pathname, + target.search, + ); + this.runHardNavigation('PUSH', () => this.options.transport.push(request)); + }; + + replace = (href: string, state?: unknown, navigateOptions?: NavigateOptions): void => { + const target = splitHref(href); + const resolved = resolveRoute(this.options.manifest, target.pathname); + if (!resolved) { + throw new Error(`No route matches "${target.pathname}"`); + } + if (resolved.container.bundle === this.options.containerBundle) { + this.memory.replace(href, state, navigateOptions); + return; + } + const request = buildStackLocation( + this.options.manifest, + target.pathname, + target.search, + ); + this.runHardNavigation('REPLACE', () => this.options.transport.replace(request)); + }; + + go = (index: number, navigateOptions?: NavigateOptions): void => { + const memoryIndex = this.memory.location.state.__TSR_index; + if (index < 0 && memoryIndex + index < 0) { + void this.options.transport.pop(); + return; + } + this.memory.go(index, navigateOptions); + }; + + back = (navigateOptions?: NavigateOptions): void => { + if (this.memory.canGoBack()) { + this.memory.back(navigateOptions); + } else { + void this.options.transport.pop(); + } + }; + + forward = (navigateOptions?: NavigateOptions): void => { + this.memory.forward(navigateOptions); + }; + + canGoBack = (): boolean => ( + this.memory.canGoBack() + || this.options.stackMirror.state.entries.length > 1 + ); + + createHref = (href: string): string => href; + + block = (blocker: NavigationBlocker): (() => void) => this.memory.block(blocker); + + flush = (): void => { + this.memory.flush(); + }; + + destroy = (): void => { + this.destroyed = true; + this.stopMirror(); + this.memory.destroy(); + }; + + notify: RouterHistory['notify'] = (action) => { + this.memory.notify(action); + }; + + private runHardNavigation( + action: 'PUSH' | 'REPLACE', + navigate: () => Promise<{ code: number; msg: string }>, + ): void { + void navigate() + .then((result) => { + if (result.code !== 1) { + this.options.onHardNavigationError?.(new Error(result.msg)); + } + }) + .catch((error: unknown) => { + this.options.onHardNavigationError?.( + error instanceof Error ? error : new Error(String(error)), + ); + }) + .finally(() => { + // TanStack waits for a history notification to settle navigate(). + // The old runtime keeps its local location because native owns the + // cross-container transition, so notify with the current snapshot. + if (!this.destroyed) { + this.memory.notify({ type: action }); + } + }); + } +} + +export function createCompositeHistory( + options: CompositeHistoryOptions, +): CompositeHistory { + return new CompositeHistory(options); +} diff --git a/packages/sparkling-router/src/container.ts b/packages/sparkling-router/src/container.ts new file mode 100644 index 00000000..39a6554c --- /dev/null +++ b/packages/sparkling-router/src/container.ts @@ -0,0 +1,18 @@ +// Copyright 2026 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 type { StackPresentation } from 'sparkling-navigation'; + +export interface ContainerOptions { + presentation?: StackPresentation; + containerOptions?: Record; +} + +/** + * Type-only authoring helper for `_container.tsx` files. The build plugin reads + * this serializable object; it is never shared across container runtimes. + */ +export function defineContainer(options: ContainerOptions): ContainerOptions { + return options; +} diff --git a/packages/sparkling-router/src/global-stack-mirror.ts b/packages/sparkling-router/src/global-stack-mirror.ts new file mode 100644 index 00000000..ec73dd13 --- /dev/null +++ b/packages/sparkling-router/src/global-stack-mirror.ts @@ -0,0 +1,75 @@ +// Copyright 2026 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 type { + NativeStackProtocol, + StackChangedEvent, + StackState, +} from 'sparkling-navigation'; + +export type StackMirrorListener = ( + state: StackState, + event?: StackChangedEvent, +) => void; + +export class GlobalStackMirror { + private currentState: StackState = { version: 0, entries: [] }; + private readonly listeners = new Set(); + private stopNativeSubscription?: () => void; + private startPromise?: Promise; + private generation = 0; + + constructor(private readonly transport: NativeStackProtocol) {} + + get state(): StackState { + return this.currentState; + } + + async start(): Promise { + if (!this.startPromise) { + const generation = this.generation; + this.stopNativeSubscription = this.transport.subscribe((event) => { + if (generation === this.generation) { + this.accept(event.state, event); + } + }); + this.startPromise = this.transport.getState() + .then((state) => { + if (generation === this.generation) { + this.accept(state); + } + return this.currentState; + }) + .catch(() => this.currentState); + } + return this.startPromise; + } + + subscribe(listener: StackMirrorListener): () => void { + this.listeners.add(listener); + listener(this.currentState); + return () => { + this.listeners.delete(listener); + }; + } + + destroy(): void { + this.generation += 1; + this.stopNativeSubscription?.(); + this.stopNativeSubscription = undefined; + this.startPromise = undefined; + this.listeners.clear(); + } + + private accept(state: StackState, event?: StackChangedEvent): void { + if (state.version < this.currentState.version) { + return; + } + if (state.version === this.currentState.version && this.currentState.entries.length > 0) { + return; + } + this.currentState = state; + this.listeners.forEach((listener) => listener(state, event)); + } +} diff --git a/packages/sparkling-router/src/manifest.ts b/packages/sparkling-router/src/manifest.ts new file mode 100644 index 00000000..efe3eefd --- /dev/null +++ b/packages/sparkling-router/src/manifest.ts @@ -0,0 +1,216 @@ +// Copyright 2026 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 { + type StackLocationRequest, + type StackPresentation, +} from 'sparkling-navigation'; + +declare const __DEV__: boolean; +declare const __webpack_public_path__: string; + +export interface RouteManifestRoute { + path: string; +} + +export interface RouteManifestContainer { + bundle: string; + presentation: StackPresentation; + routes: RouteManifestRoute[]; + containerOptions?: Record; +} + +export interface RouteManifest { + version: string; + scheme: { + base: string; + }; + containers: RouteManifestContainer[]; +} + +export interface ResolvedRoute { + container: RouteManifestContainer; + path: string; +} + +function forEachRecord( + record: Record, + callback: (key: string, value: string) => void, +): void { + Object.keys(record).forEach((key) => callback(key, record[key])); +} + +function getDevServerBaseURL(): string | undefined { + try { + if (typeof __DEV__ === 'undefined' || !__DEV__) { + return undefined; + } + const currentBundleURL = typeof lynx !== 'undefined' + ? lynx?.__globalProps?.queryItems?.url + : undefined; + if (typeof currentBundleURL === 'string') { + const parsed = new URL(currentBundleURL); + return `${parsed.origin}${parsed.pathname.slice( + 0, + parsed.pathname.lastIndexOf('/') + 1, + )}`; + } + if ( + typeof __webpack_public_path__ === 'string' + && __webpack_public_path__ + ) { + return __webpack_public_path__; + } + } catch { + // Build/runtime globals are unavailable outside device development. + } + return undefined; +} + +export function normalizePath(path: string): string { + const normalized = `/${path}`.replace(/\/+/g, '/').replace(/\/$/, ''); + return normalized || '/'; +} + +function pathSegments(path: string): string[] { + const normalized = normalizePath(path); + return normalized === '/' ? [] : normalized.slice(1).split('/'); +} + +function matchScore(pattern: string, path: string): number | null { + const patternParts = pathSegments(pattern); + const pathParts = pathSegments(path); + let score = 0; + + for (let index = 0; index < patternParts.length; index += 1) { + const segment = patternParts[index]; + const actual = pathParts[index]; + + if (segment === '$' || segment === '*') { + return score + 1; + } + if (actual === undefined) { + return null; + } + if (segment.startsWith('$') || segment.startsWith(':')) { + score += 2; + continue; + } + if (segment !== actual) { + return null; + } + score += 4; + } + + return patternParts.length === pathParts.length ? score : null; +} + +export function resolveRoute(manifest: RouteManifest, path: string): ResolvedRoute | null { + const normalizedPath = normalizePath(path); + let best: { container: RouteManifestContainer; score: number } | undefined; + + for (const container of manifest.containers) { + for (const route of container.routes) { + const score = matchScore(route.path, normalizedPath); + if (score !== null && (!best || score > best.score)) { + best = { container, score }; + } + } + } + + return best ? { container: best.container, path: normalizedPath } : null; +} + +export function buildStackLocation( + manifest: RouteManifest, + path: string, + search: Record = {}, +): StackLocationRequest & { presentation: StackPresentation } { + const resolved = resolveRoute(manifest, path); + if (!resolved) { + throw new Error(`No Sparkling container owns route "${normalizePath(path)}"`); + } + + const url = new URL(manifest.scheme.base); + forEachRecord(search, (key, value) => url.searchParams.set(key, value)); + forEachRecord(resolved.container.containerOptions ?? {}, (key, value) => { + url.searchParams.set(key, value); + }); + const devServerBaseURL = getDevServerBaseURL(); + if (devServerBaseURL) { + url.searchParams.set( + 'url', + `${devServerBaseURL.replace(/\/+$/, '')}/${resolved.container.bundle.replace(/^\/+/, '')}`, + ); + } else { + url.searchParams.set('bundle', resolved.container.bundle); + } + url.searchParams.set('__path', resolved.path); + + return { + path: resolved.path, + search, + bundle: resolved.container.bundle, + scheme: url.toString(), + presentation: resolved.container.presentation, + }; +} + +export function searchRecord(search: string): Record { + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + const result: Record = {}; + params.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +export function locationHref(path: string, search: Record): string { + const params = new URLSearchParams(); + forEachRecord(search, (key, value) => params.set(key, value)); + const query = params.toString(); + return `${normalizePath(path)}${query ? `?${query}` : ''}`; +} + +declare const lynx: + | { + __globalProps?: { + queryItems?: Record; + }; + } + | undefined; + +export function readInitialHref( + manifest: RouteManifest, + containerBundle: string, + fallback = '/', +): string { + let queryItems: Record = {}; + try { + queryItems = typeof lynx !== 'undefined' ? lynx?.__globalProps?.queryItems ?? {} : {}; + } catch { + queryItems = {}; + } + + const container = manifest.containers.find((item) => item.bundle === containerBundle); + const path = typeof queryItems.__path === 'string' + ? queryItems.__path + : container?.routes[0]?.path ?? fallback; + const reserved = new Set([ + '__path', + 'bundle', + 'url', + ...Object.keys(container?.containerOptions ?? {}), + ]); + const search: Record = {}; + + Object.keys(queryItems).forEach((key) => { + const value = queryItems[key]; + if (!reserved.has(key) && value != null) { + search[key] = String(value); + } + }); + + return locationHref(path, search); +} diff --git a/packages/sparkling-router/src/react-dom-shim.ts b/packages/sparkling-router/src/react-dom-shim.ts new file mode 100644 index 00000000..c6f5ca83 --- /dev/null +++ b/packages/sparkling-router/src/react-dom-shim.ts @@ -0,0 +1,11 @@ +// Copyright 2026 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. + +/** + * TanStack Link uses ReactDOM's flushSync for browser event ordering. + * ReactLynx has no DOM renderer, so synchronous invocation is sufficient. + */ +export function flushSync(callback: () => T): T { + return callback(); +} diff --git a/packages/sparkling-router/src/router.ts b/packages/sparkling-router/src/router.ts new file mode 100644 index 00000000..0632ed46 --- /dev/null +++ b/packages/sparkling-router/src/router.ts @@ -0,0 +1,253 @@ +// Copyright 2026 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 { + createRouter, + type AnyRoute, +} from '@tanstack/react-router'; +import { + nativeStack, + type NativeStackProtocol, + type NavResult, + type StackChangedEvent, +} from 'sparkling-navigation'; +import { + CompositeHistory, + createCompositeHistory, +} from './composite-history'; +import { GlobalStackMirror } from './global-stack-mirror'; +import { + buildStackLocation, + readInitialHref, + type RouteManifest, +} from './manifest'; +import { + bindSparklingRuntime, + type SparklingNavigationRuntime, +} from './runtime-context'; + +export interface SparklingRouterOptions { + routeTree: TRouteTree; + manifest: RouteManifest; + containerBundle: string; + initialHref?: string; + context?: unknown; + transport?: NativeStackProtocol; + onHardNavigationError?: (error: Error) => void; +} + +interface PendingResult { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + settled: boolean; +} + +class NavigationResults { + private readonly pendingByParent = new Map(); + private readonly pendingByChild = new Map(); + private readonly completedByChild = new Map(); + private readonly stop: () => void; + + constructor(private readonly transport: NativeStackProtocol) { + this.stop = transport.subscribe((event) => this.handle(event)); + } + + prepare(parentEntryId: string): { + promise: Promise; + bind: (childEntryId: string) => void; + cancel: (error: Error) => void; + } { + let pending!: PendingResult; + const promise = new Promise((resolve, reject) => { + pending = { + settled: false, + resolve: (value) => { + pending.settled = true; + resolve(value); + }, + reject: (error) => { + pending.settled = true; + reject(error); + }, + }; + const queue = this.pendingByParent.get(parentEntryId) ?? []; + queue.push(pending); + this.pendingByParent.set(parentEntryId, queue); + }); + return { + promise, + bind: (childEntryId) => { + if (pending.settled) { + return; + } + if (this.completedByChild.has(childEntryId)) { + const value = this.completedByChild.get(childEntryId); + this.completedByChild.delete(childEntryId); + this.removeFromParent(parentEntryId, pending); + pending.resolve(value); + return; + } + this.pendingByChild.set(childEntryId, pending); + }, + cancel: (error) => { + if (pending.settled) { + return; + } + this.removeFromParent(parentEntryId, pending); + for (const [childEntryId, candidate] of this.pendingByChild) { + if (candidate === pending) { + this.pendingByChild.delete(childEntryId); + } + } + pending.reject(error); + }, + }; + } + + destroy(): void { + this.stop(); + this.pendingByParent.forEach((queue) => { + queue.forEach(({ reject }) => reject(new Error('Sparkling router was destroyed'))); + }); + this.pendingByParent.clear(); + this.pendingByChild.clear(); + this.completedByChild.clear(); + } + + private handle(event: StackChangedEvent): void { + if (!event.result) { + return; + } + const fromEntryId = event.result.fromEntryId; + if (fromEntryId) { + const pending = this.pendingByChild.get(fromEntryId); + if (!pending) { + this.completedByChild.set(fromEntryId, event.result.value); + return; + } + this.pendingByChild.delete(fromEntryId); + this.removeFromParent(event.result.forEntryId, pending); + pending.resolve(event.result.value); + return; + } + + const queue = this.pendingByParent.get(event.result.forEntryId); + const pending = queue?.shift(); + pending?.resolve(event.result.value); + if (queue?.length === 0) { + this.pendingByParent.delete(event.result.forEntryId); + } + } + + private removeFromParent(parentEntryId: string, pending: PendingResult): void { + const queue = this.pendingByParent.get(parentEntryId); + if (!queue) { + return; + } + const index = queue.indexOf(pending); + if (index >= 0) { + queue.splice(index, 1); + } + if (queue.length === 0) { + this.pendingByParent.delete(parentEntryId); + } + } +} + +declare const lynx: + | { + __globalProps?: { + containerID?: string; + }; + } + | undefined; + +function readContainerEntryId(): string | undefined { + try { + return typeof lynx !== 'undefined' ? lynx?.__globalProps?.containerID : undefined; + } catch { + return undefined; + } +} + +export function createSparklingRouter( + options: SparklingRouterOptions, +) { + const transport = options.transport ?? nativeStack; + const stackMirror = new GlobalStackMirror(transport); + void stackMirror.start(); + const history = createCompositeHistory({ + manifest: options.manifest, + containerBundle: options.containerBundle, + containerEntryId: readContainerEntryId(), + initialHref: options.initialHref + ?? readInitialHref(options.manifest, options.containerBundle), + transport, + stackMirror, + onHardNavigationError: options.onHardNavigationError, + }); + const router = createRouter({ + routeTree: options.routeTree, + history, + isServer: false, + context: options.context as never, + }); + const results = new NavigationResults(transport); + + const runtime = { + router, + history, + stackMirror, + navigate( + path: string, + search: Record = {}, + navigationOptions: { replace?: boolean; animated?: boolean } = {}, + ): Promise { + const request = { + ...buildStackLocation(options.manifest, path, search), + animated: navigationOptions.animated, + }; + return navigationOptions.replace + ? transport.replace(request) + : transport.push(request); + }, + async pushWithResult( + path: string, + search: Record = {}, + ): Promise { + const request = buildStackLocation(options.manifest, path, search); + const parentEntryId = history.currentEntryId; + if (!parentEntryId) { + throw new Error('Current native stack entry is not available'); + } + const waiter = results.prepare(parentEntryId); + try { + const result = await transport.push(request); + if (result.code !== 1 || !result.entryId) { + throw new Error(result.msg || 'Native push did not return an entry ID'); + } + waiter.bind(result.entryId); + } catch (error) { + waiter.cancel(error instanceof Error ? error : new Error(String(error))); + } + return waiter.promise; + }, + pop(result?: unknown, animated?: boolean): Promise { + return transport.pop({ result, animated }); + }, + popTo: transport.popTo.bind(transport), + reset: transport.reset.bind(transport), + prefetch: transport.prefetch.bind(transport), + destroy(): void { + results.destroy(); + history.destroy(); + stackMirror.destroy(); + }, + }; + bindSparklingRuntime(router, runtime as SparklingNavigationRuntime); + return runtime; +} + +export type SparklingRouterRuntime = ReturnType; +export type { CompositeHistory }; diff --git a/packages/sparkling-router/src/runtime-context.ts b/packages/sparkling-router/src/runtime-context.ts new file mode 100644 index 00000000..7ac7d5fb --- /dev/null +++ b/packages/sparkling-router/src/runtime-context.ts @@ -0,0 +1,50 @@ +// Copyright 2026 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 { useRouter } from '@tanstack/react-router'; +import type { + NavResult, + StackPopToRequest, + StackPrefetchRequest, + StackResetRequest, +} from 'sparkling-navigation'; + +export interface SparklingNavigationRuntime { + navigate( + path: string, + search?: Record, + options?: { replace?: boolean; animated?: boolean }, + ): Promise; + pushWithResult( + path: string, + search?: Record, + ): Promise; + pop(result?: unknown, animated?: boolean): Promise; + popTo(request: StackPopToRequest): Promise; + reset(request: StackResetRequest): Promise; + prefetch(request: StackPrefetchRequest): Promise; +} + +const runtimes = new WeakMap(); + +/** @internal Associates a TanStack router with its native navigation runtime. */ +export function bindSparklingRuntime( + router: object, + runtime: SparklingNavigationRuntime, +): void { + runtimes.set(router, runtime); +} + +export function getSparklingRuntime(router: object): SparklingNavigationRuntime { + const runtime = runtimes.get(router); + if (!runtime) { + throw new Error('The router was not created by createSparklingRouter'); + } + return runtime; +} + +/** Access hard-container navigation from a generated RouterProvider tree. */ +export function useSparklingRouter(): SparklingNavigationRuntime { + return getSparklingRuntime(useRouter()); +} diff --git a/packages/sparkling-router/tsconfig.json b/packages/sparkling-router/tsconfig.json new file mode 100644 index 00000000..91cb271e --- /dev/null +++ b/packages/sparkling-router/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "noEmit": false + }, + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt index 2b7676a6..5b3238b5 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt @@ -3,6 +3,7 @@ // LICENSE file in the root directory of this source tree. package com.tiktok.sparkling +import android.app.Activity import android.content.Context import android.content.Intent import android.util.Log @@ -16,6 +17,7 @@ class Sparkling private constructor( private const val TAG = "Sparkling" const val SPARKLING_CONTEXT_CONTAINER_ID = "SparklingContextContainerId" + const val SPARKLING_CONTEXT_SCHEME = "SparklingContextScheme" const val TYPE_PAGE = 1 const val TYPE_POPUP = 2 // not implemented yet @@ -48,11 +50,15 @@ class Sparkling private constructor( processSparklingContext(sparklingContext) val intent = Intent(context, SparklingActivity::class.java) intent.putExtra(SPARKLING_CONTEXT_CONTAINER_ID, sparklingContext.containerId) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + intent.putExtra(SPARKLING_CONTEXT_SCHEME, sparklingContext.scheme) + if (context !is Activity) { + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + } SparklingContextTransferStation.saveSparklingContext(sparklingContext) context.startActivity(intent) true } catch (e: Exception) { + SparklingContextTransferStation.releaseSparklingContext(sparklingContext.containerId) Log.e(TAG, "Failed to navigate: ${e.message}") false } diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingActivity.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingActivity.kt index 299f7fec..4dde9d50 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingActivity.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingActivity.kt @@ -13,17 +13,37 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import com.tiktok.sparkling.Sparkling.Companion.SPARKLING_CONTEXT_CONTAINER_ID +import com.tiktok.sparkling.Sparkling.Companion.SPARKLING_CONTEXT_SCHEME import com.tiktok.sparkling.hybridkit.utils.ColorUtil class SparklingActivity : AppCompatActivity() { + private var sparklingContainerId: String? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val containerId = intent.getStringExtra(SPARKLING_CONTEXT_CONTAINER_ID) - val sparklingContext = SparklingContextTransferStation.getSparklingContext(containerId) + sparklingContainerId = containerId + val sparklingContext = + SparklingContextTransferStation.getSparklingContext(containerId) + ?: intent.getStringExtra(SPARKLING_CONTEXT_SCHEME)?.let { scheme -> + SparklingContext().also { restored -> + if (containerId != null) restored.containerId = containerId + restored.scheme = scheme + Sparkling.build(this, restored).processSparklingContext(restored) + SparklingContextTransferStation.saveSparklingContext(restored) + } + } + if (!SparklingNavigationStack.register(this, sparklingContext)) { + SparklingContextTransferStation.releaseSparklingContext(containerId) + finish() + return + } initStatusBar(sparklingContext) setContentView(R.layout.activity_sparkling) initToolBar(sparklingContext) - initSparklingFragment(sparklingContext) + if (savedInstanceState == null) { + initSparklingFragment(sparklingContext) + } } private fun initStatusBar(sparklingContext: SparklingContext?) { @@ -45,15 +65,18 @@ class SparklingActivity : AppCompatActivity() { fun initToolBar(sparklingContext: SparklingContext?) { val customToolbar = sparklingContext?.sparklingUIProvider?.getToolBar(this) + val activeToolbar: Toolbar if (customToolbar != null) { val defaultToolbar = findViewById(R.id.toolbar) val parent = defaultToolbar.parent as? ViewGroup parent?.removeView(defaultToolbar) parent?.addView(customToolbar, 0) setSupportActionBar(customToolbar) + activeToolbar = customToolbar } else { val toolbar = findViewById(R.id.toolbar) setSupportActionBar(toolbar) + activeToolbar = toolbar } supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -63,11 +86,7 @@ class SparklingActivity : AppCompatActivity() { if (!titleColorStr.isNullOrEmpty()) { try { val titleColor = Color.parseColor(titleColorStr) - val toolbar = (supportActionBar?.customView ?: findViewById(R.id.toolbar)) as Toolbar - toolbar.setTitleTextColor(titleColor) - - val customToolbar = sparklingContext?.sparklingUIProvider?.getToolBar(this) - customToolbar?.setTitleTextColor(titleColor) + activeToolbar.setTitleTextColor(titleColor) } catch (e: IllegalArgumentException) { } } @@ -75,14 +94,11 @@ class SparklingActivity : AppCompatActivity() { val navBarColorStr = sparklingContext?.hybridSchemeParam?.navBarColor if (!navBarColorStr.isNullOrEmpty()) { val navBarColor = ColorUtil.parseColorSafely(navBarColorStr) - val activeToolbar = - sparklingContext?.sparklingUIProvider?.getToolBar(this) - ?: findViewById(R.id.toolbar) - activeToolbar?.setBackgroundColor(navBarColor) + activeToolbar.setBackgroundColor(navBarColor) } - ((supportActionBar?.customView ?: findViewById(R.id.toolbar)) as Toolbar).setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() + activeToolbar.setNavigationOnClickListener { + onBackPressed() } } @@ -117,13 +133,23 @@ class SparklingActivity : AppCompatActivity() { if (isTaskRoot) { val currentTime = System.currentTimeMillis() if (currentTime - lastBackPressedTime < DOUBLE_CLICK_EXIT_INTERVAL) { + SparklingNavigationStack.markUserBack(sparklingContainerId) super.onBackPressed() } else { Toast.makeText(this, getString(R.string.click_again_to_exit), Toast.LENGTH_SHORT).show() lastBackPressedTime = currentTime } } else { + SparklingNavigationStack.markUserBack(sparklingContainerId) super.onBackPressed() } } + + override fun onDestroy() { + if (isFinishing) { + SparklingNavigationStack.unregister(sparklingContainerId) + SparklingContextTransferStation.releaseSparklingContext(sparklingContainerId) + } + super.onDestroy() + } } diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContextTransferStation.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContextTransferStation.kt index e15f6d92..7b1ef311 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContextTransferStation.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContextTransferStation.kt @@ -3,17 +3,20 @@ // LICENSE file in the root directory of this source tree. package com.tiktok.sparkling +import java.util.concurrent.ConcurrentHashMap + object SparklingContextTransferStation { - private val sparklingContextMap = mutableMapOf() + private val sparklingContextMap = ConcurrentHashMap() fun saveSparklingContext(context: SparklingContext) { sparklingContextMap[context.containerId] = context } - fun getSparklingContext(containerId: String?): SparklingContext? = sparklingContextMap[containerId] + fun getSparklingContext(containerId: String?): SparklingContext? = + containerId?.let(sparklingContextMap::get) fun releaseSparklingContext(containerId: String?) { - sparklingContextMap.remove(containerId) + containerId?.let(sparklingContextMap::remove) } @JvmStatic diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt index 49bc00e9..f5a801e9 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt @@ -63,6 +63,13 @@ class SparklingFragment : Fragment() { sparklingView?.getKitView()?.onHide() } + override fun onDestroyView() { + sparklingView?.release() + sparklingView = null + hasLoad = false + super.onDestroyView() + } + fun loadUrl() { sparklingView?.loadUrl() } diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingNavigationStack.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingNavigationStack.kt new file mode 100644 index 00000000..07a190a4 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingNavigationStack.kt @@ -0,0 +1,413 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling + +import android.app.Activity +import android.content.Context +import android.net.Uri +import com.tiktok.sparkling.hybridkit.KitViewManager +import org.json.JSONObject +import java.lang.ref.WeakReference +import java.util.ArrayDeque + +data class SparklingNavigationTarget( + val path: String, + val search: Map = emptyMap(), + val bundle: String, + val scheme: String, + val presentation: String = "push", +) + +data class SparklingNavigationResult( + val success: Boolean, + val message: String, + val entryId: String? = null, +) + +private data class AndroidStackEntry( + val id: String, + var path: String, + var search: Map, + val bundle: String, + val presentation: String, + var returnToEntryId: String? = null, + var pendingLaunch: Boolean = false, + var activity: WeakReference? = null, +) { + fun toMap(): Map = + mapOf( + "id" to id, + "path" to path, + "search" to search.toMap(), + "bundle" to bundle, + "presentation" to presentation, + ) +} + +/** + * Android implementation of Sparkling's hard-container source of truth. + * + * Activities register themselves on creation and are held weakly. Commands + * update the ordered mirror before broadcasting through every live KitView. + */ +object SparklingNavigationStack { + const val STACK_CHANGED_EVENT = "router.stackchanged" + + private var version = 0 + private val orderedIds = mutableListOf() + private val entries = linkedMapOf() + private val pendingReasons = mutableMapOf() + private val cancelledLaunches = mutableSetOf() + private val publicationQueue = ArrayDeque() + private var isPublishing = false + + @Synchronized + fun register( + activity: Activity, + sparklingContext: SparklingContext?, + ): Boolean { + val context = sparklingContext ?: return false + if (cancelledLaunches.remove(context.containerId)) { + return false + } + val existing = entries[context.containerId] + if (existing != null) { + existing.activity = WeakReference(activity) + existing.pendingLaunch = false + return true + } + val target = targetFromScheme(context.scheme.orEmpty()) + entries[context.containerId] = + AndroidStackEntry( + id = context.containerId, + path = target.path, + search = target.search, + bundle = target.bundle, + presentation = target.presentation, + returnToEntryId = orderedIds.lastOrNull(), + activity = WeakReference(activity), + ) + orderedIds += context.containerId + publish("system") + return true + } + + @Synchronized + fun unregister(containerId: String?) { + val id = containerId ?: return + val reason = pendingReasons.remove(id) ?: "system" + removeRecord(id, reason, null, publishChange = true) + } + + @Synchronized + fun push( + context: Context, + target: SparklingNavigationTarget, + usePrefetched: Boolean = false, + sourceEntryId: String? = null, + publishChange: Boolean = true, + ): SparklingNavigationResult { + if (target.scheme.isBlank()) { + return SparklingNavigationResult(false, "scheme is required") + } + if (target.presentation !in setOf("push", "modal")) { + return SparklingNavigationResult(false, "Unknown presentation: ${target.presentation}") + } + if (target.presentation != "push") { + return SparklingNavigationResult(false, "modal presentation is not implemented on Android") + } + if (usePrefetched) { + return SparklingNavigationResult(false, "Android container prefetch is not implemented") + } + if (sourceEntryId != null) { + if (!entries.containsKey(sourceEntryId)) { + return SparklingNavigationResult(false, "Unknown source entry: $sourceEntryId") + } + if (orderedIds.lastOrNull() != sourceEntryId) { + return SparklingNavigationResult(false, "Source entry is not on top: $sourceEntryId") + } + } + val sparklingContext = SparklingContext().also { it.scheme = target.scheme } + val id = sparklingContext.containerId + entries[id] = + AndroidStackEntry( + id = id, + path = target.path, + search = target.search.toMap(), + bundle = target.bundle, + presentation = target.presentation, + returnToEntryId = sourceEntryId ?: orderedIds.lastOrNull(), + pendingLaunch = true, + ) + orderedIds += id + val success = Sparkling.build(context, sparklingContext).navigate() + if (!success) { + entries.remove(id) + orderedIds.remove(id) + return SparklingNavigationResult(false, "Unable to start SparklingActivity") + } + if (publishChange) publish("push") + return SparklingNavigationResult(true, "ok", id) + } + + @Synchronized + fun pop( + entryId: String?, + result: Any? = null, + reason: String = "pop", + ): SparklingNavigationResult { + val id = entryId ?: orderedIds.lastOrNull() + ?: return SparklingNavigationResult(false, "Stack is empty") + val entry = entries[id] + ?: return SparklingNavigationResult(false, "Unknown entry: $id") + val activity = entry.activity?.get() + if (entry.pendingLaunch && activity == null) { + cancelledLaunches += id + } + removeRecord(id, reason, result, publishChange = true) + activity?.finish() + return SparklingNavigationResult(true, "ok", id) + } + + @Synchronized + fun popTo(entryId: String): SparklingNavigationResult { + val targetIndex = orderedIds.indexOf(entryId) + if (targetIndex < 0) { + return SparklingNavigationResult(false, "Unknown entry: $entryId") + } + val removing = orderedIds.drop(targetIndex + 1).reversed() + if (removing.isEmpty()) { + return SparklingNavigationResult(true, "ok", entryId) + } + removing.forEach { id -> + val entry = entries[id] + val activity = entry?.activity?.get() + if (entry?.pendingLaunch == true && activity == null) { + cancelledLaunches += id + } + removeRecord(id, "pop", null, publishChange = false) + activity?.finish() + } + publish("pop") + return SparklingNavigationResult(true, "ok", entryId) + } + + @Synchronized + fun replace( + context: Context, + sourceEntryId: String?, + target: SparklingNavigationTarget, + ): SparklingNavigationResult { + val sourceId = sourceEntryId ?: orderedIds.lastOrNull() + ?: return SparklingNavigationResult(false, "Stack is empty") + val source = entries[sourceId] + ?: return SparklingNavigationResult(false, "Unknown source entry: $sourceId") + if (orderedIds.lastOrNull() != sourceId) { + return SparklingNavigationResult(false, "Only the top entry can be replaced") + } + val pushed = + push( + context, + target, + sourceEntryId = sourceId, + publishChange = false, + ) + if (!pushed.success) return pushed + val replacementId = pushed.entryId + ?: return SparklingNavigationResult(false, "Replacement entry ID is unavailable") + entries[replacementId]?.returnToEntryId = source.returnToEntryId + if (sourceId != replacementId) { + val activity = source.activity?.get() + removeRecord(sourceId, "replace", null, publishChange = false) + activity?.finish() + } + publish("replace") + return pushed + } + + @Synchronized + fun reset( + context: Context, + targets: List, + ): SparklingNavigationResult { + if (targets.isEmpty()) { + return SparklingNavigationResult(false, "reset requires entries") + } + val invalidTarget = + targets.firstOrNull { + it.scheme.isBlank() || it.presentation != "push" + } + if (invalidTarget != null) { + return SparklingNavigationResult( + false, + "Android reset requires valid push entries", + ) + } + + val oldEntries = orderedIds.toList() + val stagedIds = mutableListOf() + var lastResult = SparklingNavigationResult(true, "ok") + targets.forEach { target -> + lastResult = + push( + context, + target, + sourceEntryId = orderedIds.lastOrNull(), + publishChange = false, + ) + if (!lastResult.success) { + stagedIds.reversed().forEach { id -> + val entry = entries[id] + val activity = entry?.activity?.get() + if (entry?.pendingLaunch == true && activity == null) { + cancelledLaunches += id + } + removeRecord(id, "reset", null, publishChange = false) + activity?.finish() + } + return lastResult + } + lastResult.entryId?.let(stagedIds::add) + } + + oldEntries.reversed().forEach { id -> + val activity = entries[id]?.activity?.get() + removeRecord(id, "reset", null, publishChange = false) + activity?.finish() + } + var previousId: String? = null + stagedIds.forEach { id -> + entries[id]?.returnToEntryId = previousId + previousId = id + } + publish("reset") + return lastResult + } + + @Synchronized + fun prefetch( + context: Context, + target: SparklingNavigationTarget, + ): SparklingNavigationResult { + if (target.scheme.isBlank()) { + return SparklingNavigationResult(false, "scheme is required") + } + return SparklingNavigationResult( + false, + "Android container prefetch is not implemented", + ) + } + + @Synchronized + fun syncOwnLocation( + entryId: String?, + path: String, + search: Map, + ): SparklingNavigationResult { + val id = entryId + ?: return SparklingNavigationResult(false, "Source entry is required") + val entry = entries[id] + ?: return SparklingNavigationResult(false, "Unknown source entry: $id") + if (entry.path == path && entry.search == search) { + return SparklingNavigationResult(true, "ok", id) + } + entry.path = path + entry.search = search.toMap() + publish("replace") + return SparklingNavigationResult(true, "ok", id) + } + + @Synchronized + fun markUserBack(containerId: String?) { + if (containerId != null) pendingReasons[containerId] = "user-back-button" + } + + @Synchronized + fun stateMap(): Map = + mapOf( + "version" to version, + "entries" to orderedIds.mapNotNull { entries[it]?.toMap() }, + ) + + private fun publish( + reason: String, + result: Map? = null, + ) { + version += 1 + val event = + mutableMapOf( + "state" to stateMap(), + "reason" to reason, + ) + if (result != null) event["result"] = result + val payload = JSONObject(event) + publicationQueue.addLast(payload) + if (isPublishing) return + + isPublishing = true + try { + while (publicationQueue.isNotEmpty()) { + val next = publicationQueue.removeFirst() + KitViewManager.getKitViews().values.forEach { kitView -> + runCatching { + kitView.sendEventByJSON(STACK_CHANGED_EVENT, next) + } + } + } + } finally { + isPublishing = false + } + } + + private fun targetFromScheme(scheme: String): SparklingNavigationTarget { + val query: MutableMap = + runCatching { + val uri = Uri.parse(scheme) + if (!uri.isHierarchical) { + mutableMapOf() + } else { + uri.queryParameterNames + .associateWith { uri.getQueryParameter(it).orEmpty() } + .toMutableMap() + } + }.getOrElse { mutableMapOf() } + val path = query.remove("__path") ?: "/" + val bundle = query.remove("bundle").orEmpty() + query.remove("url") + return SparklingNavigationTarget( + path = path, + search = query, + bundle = bundle, + scheme = scheme, + ) + } + + private fun removeRecord( + id: String, + reason: String, + resultValue: Any?, + publishChange: Boolean, + ) { + val index = orderedIds.indexOf(id) + val removedEntry = entries[id] + if (index < 0 || removedEntry == null) return + entries.remove(id) + orderedIds.removeAt(index) + pendingReasons.remove(id) + if (publishChange) { + val parentId = removedEntry.returnToEntryId?.takeIf(entries::containsKey) + val result = + if (parentId != null && resultValue != null) { + mapOf( + "forEntryId" to parentId, + "fromEntryId" to id, + "value" to resultValue, + ) + } else { + null + } + publish(reason, result) + } + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/KitViewManager.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/KitViewManager.kt index 7e1c5fc1..453c65f7 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/KitViewManager.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/KitViewManager.kt @@ -68,9 +68,18 @@ object KitViewManager { return result } - fun removeKitView(containerId: String) { - val ref = kitViewMap.remove(containerId) ?: return + fun removeKitView( + containerId: String, + expectedView: IKitView? = null, + ) { + val ref = kitViewMap[containerId] ?: return val view = ref.get() + if (expectedView != null && view !== expectedView) { + return + } + if (!kitViewMap.remove(containerId, ref)) { + return + } if (destroyedListeners.isNotEmpty()) { destroyedListeners.forEach { runCatching { it.onKitViewDestroyed(containerId, view) } } } diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt index 6d1a7024..b3048578 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt @@ -215,7 +215,7 @@ class SimpleLynxKitView : hybridContext.bridge?.release() lynxKitLifeCycle?.onDestroy(this) GlobalPropsUtils.instance.flushGlobalProps(hybridContext.containerId) - KitViewManager.removeKitView(hybridContext.containerId) + KitViewManager.removeKitView(hybridContext.containerId, this) } override fun hasDestroyed(): Boolean = hasDestroyed diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKContainerRegistry.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKContainerRegistry.swift new file mode 100644 index 00000000..1ddbe52e --- /dev/null +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKContainerRegistry.swift @@ -0,0 +1,48 @@ +// Copyright 2026 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 Foundation +import UIKit + +/// Weak container lookup used by URL-first navigation and targeted close. +public final class SPKContainerRegistry { + public static let shared = SPKContainerRegistry() + + private let containers = NSMapTable( + keyOptions: .strongMemory, + valueOptions: .weakMemory + ) + private let lock = NSRecursiveLock() + + private init() {} + + public func register(_ container: SPKViewController) { + guard !container.containerID.isEmpty else { + return + } + lock.lock() + containers.setObject(container, forKey: container.containerID as NSString) + lock.unlock() + } + + public func unregister(containerID: String) { + lock.lock() + containers.removeObject(forKey: containerID as NSString) + lock.unlock() + } + + public func container(for containerID: String) -> SPKViewController? { + lock.lock() + defer { lock.unlock() } + return containers.object(forKey: containerID as NSString) + } + + public func allContainers() -> [SPKViewController] { + lock.lock() + defer { lock.unlock() } + return containers.objectEnumerator()?.allObjects.compactMap { + $0 as? SPKViewController + } ?? [] + } +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKNavigationStack.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKNavigationStack.swift new file mode 100644 index 00000000..2377c695 --- /dev/null +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKNavigationStack.swift @@ -0,0 +1,725 @@ +// Copyright 2026 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 Foundation +import UIKit + +public enum SPKStackChangeReason: String { + case push + case pop + case replace + case reset + case userBackGesture = "user-back-gesture" + case userBackButton = "user-back-button" + case system +} + +public struct SPKNavigationTarget { + public var path: String + public var search: [String: String] + public var bundle: String + public var scheme: String + public var presentation: String + + public init( + path: String, + search: [String: String] = [:], + bundle: String, + scheme: String, + presentation: String = "push" + ) { + self.path = path + self.search = search + self.bundle = bundle + self.scheme = scheme + self.presentation = presentation == "modal" ? "modal" : "push" + } + + static func from(urlString: String) -> SPKNavigationTarget { + guard let components = URLComponents(string: urlString) else { + return SPKNavigationTarget( + path: "/", + bundle: "", + scheme: urlString + ) + } + var query: [String: String] = [:] + components.queryItems?.forEach { item in + if let value = item.value { + query[item.name] = value + } + } + let path = query.removeValue(forKey: "__path") ?? "/" + let bundle = query.removeValue(forKey: "bundle") ?? "" + query.removeValue(forKey: "url") + return SPKNavigationTarget( + path: path, + search: query, + bundle: bundle, + scheme: urlString + ) + } +} + +public struct SPKNavigationResult { + public var success: Bool + public var message: String + public var entryId: String? + + public init(success: Bool, message: String, entryId: String? = nil) { + self.success = success + self.message = message + self.entryId = entryId + } +} + +private struct SPKStackEntryRecord { + var id: String + var path: String + var search: [String: String] + var bundle: String + var presentation: String + var returnToEntryId: String? + + var dictionary: [String: Any] { + return [ + "id": id, + "path": path, + "search": search, + "bundle": bundle, + "presentation": presentation, + ] + } +} + +private struct SPKPrefetchedContainer { + var container: SPKViewController + var expiresAt: Date +} + +/// Native source of truth for hard-container navigation. +public final class SPKNavigationStack { + public static let shared = SPKNavigationStack() + public static let changedEvent = "router.stackchanged" + + private var version = 0 + private var orderedEntryIds: [String] = [] + private var entries: [String: SPKStackEntryRecord] = [:] + private var prefetched: [String: SPKPrefetchedContainer] = [:] + private var prefetchTimer: Timer? + private var publicationQueue: [[String: Any]] = [] + private var isPublishing = false + private let prefetchTTL: TimeInterval = 30 + + private init() { + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.removeAllPrefetches() + } + } + + public var stateDictionary: [String: Any] { + return [ + "version": version, + "entries": orderedEntryIds.compactMap { entries[$0]?.dictionary }, + ] + } + + @discardableResult + public func registerIfNeeded(_ container: SPKViewController) -> String? { + guard !container.containerID.isEmpty else { + return nil + } + let id = container.containerID + SPKContainerRegistry.shared.register(container) + guard entries[id] == nil else { + return id + } + + let target = SPKNavigationTarget.from( + urlString: container.context?.originURL + ?? container.originURL?.absoluteString + ?? "" + ) + let navigationController = container.navigationController + let presentation = + navigationController?.presentingViewController != nil + && navigationController?.viewControllers.first === container + ? "modal" + : "push" + entries[id] = SPKStackEntryRecord( + id: id, + path: target.path, + search: target.search, + bundle: target.bundle, + presentation: presentation, + returnToEntryId: orderedEntryIds.last + ) + orderedEntryIds.append(id) + publish(reason: .system) + return id + } + + public func push( + _ target: SPKNavigationTarget, + context: SPKContext?, + animated: Bool = true, + usePrefetched: Bool = false, + sourceEntryId: String? = nil + ) -> (SPKViewController?, SPKNavigationResult) { + return push( + target, + context: context, + animated: animated, + usePrefetched: usePrefetched, + sourceEntryId: sourceEntryId, + publishChange: true + ) + } + + public func pop( + entryId: String? = nil, + result: Any? = nil, + animated: Bool = true, + reason: SPKStackChangeReason = .pop + ) -> SPKNavigationResult { + return pop( + entryId: entryId, + result: result, + animated: animated, + reason: reason, + publishChange: true + ) + } + + public func popTo(entryId: String, animated: Bool = true) -> SPKNavigationResult { + guard let targetIndex = orderedEntryIds.firstIndex(of: entryId) else { + return SPKNavigationResult(success: false, message: "Unknown entry: \(entryId)") + } + let removed = Array(orderedEntryIds.suffix(from: targetIndex + 1)).reversed() + guard !removed.isEmpty else { + return SPKNavigationResult(success: true, message: "ok", entryId: entryId) + } + for id in removed { + let response = pop( + entryId: id, + result: nil, + animated: animated, + reason: .pop, + publishChange: false + ) + if !response.success { + return response + } + } + publish(reason: .pop) + return SPKNavigationResult(success: true, message: "ok", entryId: entryId) + } + + public func replace( + entryId: String?, + target: SPKNavigationTarget, + context: SPKContext?, + animated: Bool = true + ) -> SPKNavigationResult { + guard let sourceId = entryId ?? orderedEntryIds.last, + let sourceIndex = orderedEntryIds.firstIndex(of: sourceId), + let sourceEntry = entries[sourceId], + let source = SPKContainerRegistry.shared.container(for: sourceId) + else { + return SPKNavigationResult(success: false, message: "Source container not found") + } + guard orderedEntryIds.last == sourceId else { + return SPKNavigationResult( + success: false, + message: "Only the top entry can be replaced" + ) + } + guard sourceEntry.presentation == target.presentation else { + return SPKNavigationResult( + success: false, + message: "Changing presentation during replace is not supported" + ) + } + guard let replacement = createContainer(target, context: context) else { + return SPKNavigationResult(success: false, message: "Unable to create container") + } + let replacementId = replacement.containerID + guard !replacementId.isEmpty, entries[replacementId] == nil else { + return SPKNavigationResult(success: false, message: "Container ID is unavailable") + } + + guard let navigationController = source.navigationController, + let controllerIndex = navigationController.viewControllers.firstIndex( + where: { $0 === source } + ) + else { + return SPKNavigationResult( + success: false, + message: "Source navigation controller not found" + ) + } + var controllers = navigationController.viewControllers + controllers[controllerIndex] = replacement + navigationController.setViewControllers(controllers, animated: animated) + + removeRecord(sourceId) + SPKContainerRegistry.shared.register(replacement) + entries[replacementId] = SPKStackEntryRecord( + id: replacementId, + path: target.path, + search: target.search, + bundle: target.bundle, + presentation: target.presentation, + returnToEntryId: sourceEntry.returnToEntryId + ) + orderedEntryIds.insert(replacementId, at: sourceIndex) + publish(reason: .replace) + return SPKNavigationResult( + success: true, + message: "ok", + entryId: replacementId + ) + } + + public func reset( + targets: [SPKNavigationTarget], + context: SPKContext?, + animated: Bool = false + ) -> SPKNavigationResult { + guard !targets.isEmpty else { + return SPKNavigationResult(success: false, message: "reset requires entries") + } + guard targets.allSatisfy({ $0.presentation == "push" }) else { + return SPKNavigationResult( + success: false, + message: "Atomic reset currently supports push presentation only" + ) + } + + var prepared: [(SPKNavigationTarget, SPKViewController)] = [] + var preparedIds = Set() + for target in targets { + guard let container = createContainer(target, context: context) else { + return SPKNavigationResult( + success: false, + message: "Unable to create reset container" + ) + } + let id = container.containerID + guard !id.isEmpty, entries[id] == nil, preparedIds.insert(id).inserted else { + return SPKNavigationResult( + success: false, + message: "Reset container ID is unavailable" + ) + } + prepared.append((target, container)) + } + + let oldIds = orderedEntryIds + let oldContainers = oldIds.compactMap { + SPKContainerRegistry.shared.container(for: $0) + } + let baseNavigationController = + oldContainers.first(where: { + entries[$0.containerID]?.presentation == "push" + })?.navigationController + ?? SPKResponder.topViewController?.navigationController + ?? (SPKResponder.topViewController as? UINavigationController) + guard let baseNavigationController = baseNavigationController else { + return SPKNavigationResult( + success: false, + message: "No navigation host available for reset" + ) + } + + let oldContainerIds = Set(oldIds) + let preserved = baseNavigationController.viewControllers.filter { controller in + guard let sparkling = controller as? SPKViewController else { + return true + } + return !oldContainerIds.contains(sparkling.containerID) + } + let modalNavigationControllers = oldContainers.compactMap { container -> UINavigationController? in + guard let navigationController = container.navigationController, + navigationController.presentingViewController != nil, + navigationController.viewControllers.first === container + else { + return nil + } + return navigationController + } + var dismissed = Set() + modalNavigationControllers.reversed().forEach { navigationController in + if dismissed.insert(ObjectIdentifier(navigationController)).inserted { + navigationController.dismiss(animated: false) + } + } + baseNavigationController.setViewControllers( + preserved + prepared.map { $0.1 }, + animated: animated + ) + + oldIds.forEach(removeRecord) + var previousId: String? + prepared.forEach { target, container in + let id = container.containerID + SPKContainerRegistry.shared.register(container) + entries[id] = SPKStackEntryRecord( + id: id, + path: target.path, + search: target.search, + bundle: target.bundle, + presentation: target.presentation, + returnToEntryId: previousId + ) + orderedEntryIds.append(id) + previousId = id + } + publish(reason: .reset) + return SPKNavigationResult(success: true, message: "ok", entryId: previousId) + } + + public func prefetch( + _ target: SPKNavigationTarget, + context: SPKContext? + ) -> SPKNavigationResult { + purgeExpiredPrefetches() + guard !target.scheme.isEmpty else { + return SPKNavigationResult(success: false, message: "scheme is required") + } + guard let container = createContainer(target, context: context), + !container.containerID.isEmpty + else { + return SPKNavigationResult(success: false, message: "Unable to create container") + } + prefetched[target.scheme] = SPKPrefetchedContainer( + container: container, + expiresAt: Date().addingTimeInterval(prefetchTTL) + ) + schedulePrefetchPurge() + return SPKNavigationResult( + success: true, + message: "ok", + entryId: container.containerID + ) + } + + public func syncOwnLocation( + entryId: String?, + path: String, + search: [String: String] + ) -> SPKNavigationResult { + guard let id = entryId, var entry = entries[id] else { + return SPKNavigationResult(success: false, message: "Source container not found") + } + guard entry.path != path || entry.search != search else { + return SPKNavigationResult(success: true, message: "ok", entryId: id) + } + entry.path = path + entry.search = search + entries[id] = entry + publish(reason: .replace) + return SPKNavigationResult(success: true, message: "ok", entryId: id) + } + + public func didRemove( + containerID: String, + reason: SPKStackChangeReason, + removesNavigationController: Bool = false + ) { + guard entries[containerID] != nil else { + return + } + let removedIds = SPKContainerRegistry.shared.container(for: containerID) + .map { + removalIds( + for: $0, + requestedId: containerID, + forceNavigationController: removesNavigationController + ) + } + ?? [containerID] + removedIds.forEach(removeRecord) + publish(reason: reason) + } + + private func createContainer( + _ target: SPKNavigationTarget, + context: SPKContext? + ) -> SPKViewController? { + guard !target.scheme.isEmpty, + let container = SPKRouter.create( + withURL: target.scheme, + context: context + ) as? SPKViewController + else { + return nil + } + container.loadViewIfNeeded() + return container + } + + private func push( + _ target: SPKNavigationTarget, + context: SPKContext?, + animated: Bool, + usePrefetched: Bool, + sourceEntryId: String?, + publishChange: Bool + ) -> (SPKViewController?, SPKNavigationResult) { + purgeExpiredPrefetches() + guard !target.scheme.isEmpty else { + return (nil, SPKNavigationResult(success: false, message: "scheme is required")) + } + if let sourceEntryId = sourceEntryId { + guard entries[sourceEntryId] != nil else { + return ( + nil, + SPKNavigationResult( + success: false, + message: "Unknown source entry: \(sourceEntryId)" + ) + ) + } + guard orderedEntryIds.last == sourceEntryId else { + return ( + nil, + SPKNavigationResult( + success: false, + message: "Source entry is not on top: \(sourceEntryId)" + ) + ) + } + } + + let container: SPKViewController + if usePrefetched, let cached = prefetched.removeValue(forKey: target.scheme) { + container = cached.container + } else { + guard let created = createContainer(target, context: context) else { + return ( + nil, + SPKNavigationResult(success: false, message: "Unable to create container") + ) + } + container = created + } + + let id = container.containerID + guard !id.isEmpty, entries[id] == nil else { + return ( + nil, + SPKNavigationResult(success: false, message: "Container ID is unavailable") + ) + } + + guard present(container, presentation: target.presentation, animated: animated) else { + return ( + nil, + SPKNavigationResult(success: false, message: "No navigation host available") + ) + } + SPKContainerRegistry.shared.register(container) + entries[id] = SPKStackEntryRecord( + id: id, + path: target.path, + search: target.search, + bundle: target.bundle, + presentation: target.presentation, + returnToEntryId: sourceEntryId ?? orderedEntryIds.last + ) + if !orderedEntryIds.contains(id) { + orderedEntryIds.append(id) + } + if publishChange { + publish(reason: .push) + } + return ( + container, + SPKNavigationResult(success: true, message: "ok", entryId: id) + ) + } + + private func pop( + entryId: String?, + result: Any?, + animated: Bool, + reason: SPKStackChangeReason, + publishChange: Bool + ) -> SPKNavigationResult { + guard let id = entryId ?? orderedEntryIds.last, + let entry = entries[id], + let container = SPKContainerRegistry.shared.container(for: id) + else { + return SPKNavigationResult(success: false, message: "Container not found") + } + let removedIds = removalIds(for: container, requestedId: id) + guard close(container, animated: animated) else { + return SPKNavigationResult(success: false, message: "Unable to close container") + } + removedIds.forEach(removeRecord) + if publishChange { + let resultEvent: [String: Any]? = { + guard let parentId = entry.returnToEntryId, + entries[parentId] != nil, + let result = result + else { + return nil + } + return [ + "forEntryId": parentId, + "fromEntryId": id, + "value": result, + ] + }() + publish(reason: reason, result: resultEvent) + } + return SPKNavigationResult(success: true, message: "ok", entryId: id) + } + + private func present( + _ container: SPKViewController, + presentation: String, + animated: Bool + ) -> Bool { + guard let top = SPKResponder.topViewController else { + return false + } + if presentation == "modal" { + let navigationController = UINavigationController(rootViewController: container) + top.present(navigationController, animated: animated) + return true + } + if let navigationController = top.navigationController + ?? top.children.last(where: { $0 is UINavigationController }) as? UINavigationController + { + navigationController.pushViewController(container, animated: animated) + return true + } + return false + } + + private func close(_ container: SPKViewController, animated: Bool) -> Bool { + if let navigationController = container.navigationController { + if navigationController.viewControllers.first === container, + navigationController.presentingViewController != nil + { + navigationController.dismiss(animated: animated) + return true + } + if navigationController.viewControllers.contains(container) { + if navigationController.topViewController === container { + guard navigationController.popViewController(animated: animated) === container + else { + return false + } + } else { + navigationController.setViewControllers( + navigationController.viewControllers.filter { $0 !== container }, + animated: animated + ) + } + return true + } + } + if container.presentingViewController != nil { + container.dismiss(animated: animated) + return true + } + return false + } + + private func removalIds( + for container: SPKViewController, + requestedId: String, + forceNavigationController: Bool = false + ) -> [String] { + guard let navigationController = container.navigationController else { + return [requestedId] + } + let dismissesNavigationController = + forceNavigationController + || navigationController.isBeingDismissed + || ( + navigationController.viewControllers.first === container + && navigationController.presentingViewController != nil + ) + guard dismissesNavigationController else { + return [requestedId] + } + return orderedEntryIds.filter { id in + SPKContainerRegistry.shared.container(for: id)?.navigationController + === navigationController + } + } + + private func removeRecord(_ id: String) { + orderedEntryIds.removeAll { $0 == id } + entries.removeValue(forKey: id) + SPKContainerRegistry.shared.unregister(containerID: id) + } + + private func publish( + reason: SPKStackChangeReason, + result: [String: Any]? = nil + ) { + version += 1 + var event: [String: Any] = [ + "state": stateDictionary, + "reason": reason.rawValue, + ] + if let result = result { + event["result"] = result + } + publicationQueue.append(event) + guard !isPublishing else { + return + } + isPublishing = true + while !publicationQueue.isEmpty { + let nextEvent = publicationQueue.removeFirst() + SPKContainerRegistry.shared.allContainers().forEach { container in + container.send( + event: Self.changedEvent, + params: nextEvent, + callback: nil + ) + } + } + isPublishing = false + } + + private func purgeExpiredPrefetches() { + let now = Date() + let expired = prefetched.filter { $0.value.expiresAt <= now } + expired.forEach { key, _ in + prefetched.removeValue(forKey: key) + } + schedulePrefetchPurge() + } + + private func schedulePrefetchPurge() { + prefetchTimer?.invalidate() + guard let nextExpiry = prefetched.values.map(\.expiresAt).min() else { + prefetchTimer = nil + return + } + prefetchTimer = Timer.scheduledTimer( + withTimeInterval: max(nextExpiry.timeIntervalSinceNow, 0.1), + repeats: false + ) { [weak self] _ in + self?.purgeExpiredPrefetches() + } + } + + private func removeAllPrefetches() { + prefetchTimer?.invalidate() + prefetchTimer = nil + prefetched.removeAll() + } +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter+Bridge.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter+Bridge.swift index 3663689c..1950a5c6 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter+Bridge.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter+Bridge.swift @@ -7,6 +7,15 @@ import SparklingMethod extension SPKRouter { public static func close(container: PipeContainer?) -> Bool { + if let containerID = container?.spk_containerID, + !containerID.isEmpty, + SPKNavigationStack.shared.pop( + entryId: containerID, + animated: true + ).success + { + return true + } guard let uiResponder = container as? UIResponder else { return false } @@ -36,6 +45,16 @@ extension SPKRouter { return true } + public static func close( + containerID: String, + animated: Bool = true + ) -> Bool { + return SPKNavigationStack.shared.pop( + entryId: containerID, + animated: animated + ).success + } + public static func viewController(for responder: UIResponder) -> UIViewController? { var nextRepsonder: UIResponder? = responder while let responder = nextRepsonder { diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift index 299a2300..34396af8 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift @@ -41,27 +41,32 @@ public class SPKRouter: NSObject { /// - context: The context object containing page configuration. /// - Returns: A tuple containing the created container and success status, or nil if failed. public static func open(withURL urlString: String?, context: SPKContext?) -> ((UIViewController & SPKContainerProtocol)?, Bool)? { + return open( + withURL: urlString, + context: context, + presentation: "push", + animated: true + ) + } + + public static func open( + withURL urlString: String?, + context: SPKContext?, + presentation: String, + animated: Bool + ) -> ((UIViewController & SPKContainerProtocol)?, Bool)? { guard let urlString = urlString else { return nil } context?.originURL = urlString - let container = self.create(withURL: urlString, context: context) - if let container = container as? (UIViewController & SPKContainerProtocol), - let topVC = SPKResponder.topViewController - { - if let naviVC = topVC.navigationController as? UINavigationController { - naviVC.pushViewController(container, animated: true) - } else if let naviVC = topVC.children.last as? UINavigationController { - // Currently, we only support returning a NavigationController as in SwiftUI. - // In this situation, the topVC should be the UIHostingController, and it its navigationController is null. - // We have to use topVC.children.last to get the navigationController. - naviVC.pushViewController(container, animated: true) - } else { - return (nil, false) - } - return (container, true) - } - return (nil, false) + var target = SPKNavigationTarget.from(urlString: urlString) + target.presentation = presentation + let (container, result) = SPKNavigationStack.shared.push( + target, + context: context, + animated: animated + ) + return (container, result.success) } /// Opens a URL in the system's default web browser. @@ -103,7 +108,20 @@ public class SPKRouter: NSObject { /// - If the navigation controller is presented modally, it dismisses the entire stack. /// - Does nothing if no appropriate navigation context is found. public static func closeTopViewController() { - guard let topVC = SPKResponder.topViewController, let naviVC = topVC.children.last as? UINavigationController else { + guard let topVC = SPKResponder.topViewController else { + return + } + if let sparkling = topVC as? SPKViewController, + SPKNavigationStack.shared.pop( + entryId: sparkling.containerID, + animated: true + ).success + { + return + } + guard let naviVC = topVC.navigationController + ?? topVC.children.last(where: { $0 is UINavigationController }) as? UINavigationController + else { return } if naviVC.viewControllers.count > 1 { diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift index 88c5ae27..5ea78609 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift @@ -227,6 +227,8 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { var statusBarHiddenStatus: Bool = false var _willDestory: Bool = false + var pendingStackRemovalReason: SPKStackChangeReason? + var pendingStackRemovesNavigationController = false var hasExecuteDidAppearedOnce: Bool = false var isInBackground: Bool = false @@ -373,6 +375,7 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { self.handleViewDidAppear() self.hasExecuteDidAppearedOnce = true + SPKNavigationStack.shared.registerIfNeeded(self) self.containerLifecycleDelegate?.containerViewDidAppear?(self) } @@ -384,8 +387,21 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { /// /// - Parameter animated: Whether the disappearance is animated public override func viewWillDisappear(_ animated: Bool) { + if self.isMovingFromParent + || self.isBeingDismissed + || self.navigationController?.isBeingDismissed == true + { + self.pendingStackRemovesNavigationController = + self.navigationController?.isBeingDismissed == true + self.pendingStackRemovalReason = + self.transitionCoordinator?.isInteractive == true + ? .userBackGesture + : .system + } self.transitionCoordinator?.notifyWhenInteractionChanges { [weak self] context in if context.isCancelled { + self?.pendingStackRemovalReason = nil + self?.pendingStackRemovesNavigationController = false return } self?.send( @@ -420,6 +436,16 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { self.resetStatusBarStyle() self.resetNavigationBarStyle() + if let reason = self.pendingStackRemovalReason { + SPKNavigationStack.shared.didRemove( + containerID: self.containerID, + reason: reason, + removesNavigationController: self.pendingStackRemovesNavigationController + ) + self.pendingStackRemovalReason = nil + self.pendingStackRemovesNavigationController = false + } + if self.navigationController != nil { self._willDestory = false } @@ -598,6 +624,15 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { SPKEvent.Back.actionFromKey: SPKEvent.Back.actionTypeNavBarBackPress, ]) + let stackResult = SPKNavigationStack.shared.pop( + entryId: self.containerID, + animated: true, + reason: .userBackButton + ) + if stackResult.success { + return + } + if self.navigationController?.viewControllers.count ?? 0 > 1 { //MARK: currently only support lynx self.navigationController?.popViewController(animated: true) diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Utils/SPKResponder.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Utils/SPKResponder.swift index 33d48855..5ca4c1a8 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Utils/SPKResponder.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Utils/SPKResponder.swift @@ -64,9 +64,11 @@ class SPKResponder: NSObject { } static func isTopViewController(viewController: UIViewController?) -> Bool { - guard let topViewController = topViewController else { + guard let viewController = viewController, + let topViewController = topViewController + else { return false } - return self.topViewController == topViewController + return viewController === topViewController } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a974c7f2..d913f150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,10 +82,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + version: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) ts-jest: specifier: ^29.1.2 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -101,10 +101,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + version: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) ts-jest: specifier: ^29.1.2 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -120,10 +120,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + version: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) ts-jest: specifier: ^29.1.2 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -172,7 +172,7 @@ importers: version: 18.3.28 '@vitest/coverage-v8': specifier: ^3.1.2 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@26.1.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -190,7 +190,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.2 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@26.1.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) packages/sparkling-app-cli: dependencies: @@ -295,6 +295,68 @@ importers: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.17)(typescript@5.9.3) + packages/sparkling-router: + dependencies: + '@lynx-js/react': + specifier: '>=0.116.0' + version: 0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14) + '@tanstack/history': + specifier: 1.162.0 + version: 1.162.0 + '@tanstack/react-router': + specifier: 1.170.18 + version: 1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + sparkling-navigation: + specifier: workspace:* + version: link:../methods/sparkling-navigation + url-search-params-polyfill: + specifier: 8.2.5 + version: 8.2.5 + devDependencies: + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.2 + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/sparkling-router-plugin: + dependencies: + '@lynx-js/rspeedy': + specifier: '>=0.13.0' + version: 0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0) + '@rsbuild/core': + specifier: '>=1.7.0' + version: 1.7.2 + '@tanstack/router-generator': + specifier: 1.167.21 + version: 1.167.21 + '@tanstack/virtual-file-routes': + specifier: ^1.162.0 + version: 1.162.0 + devDependencies: + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.2 + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/sparkling-sdk: {} packages/sparkling-types: @@ -411,7 +473,7 @@ importers: version: 3.8.3 sparkling-app-cli: specifier: ~2.1.0-rc.12 - version: 2.1.0-rc.12(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3)(webpack@5.105.0) + version: 2.1.0-rc.12(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/node@26.1.1)(typescript@5.9.3)(webpack@5.105.0) sparkling-types: specifier: ~2.1.0-rc.12 version: 2.1.0-rc.20(@lynx-js/types@3.7.0) @@ -420,7 +482,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + version: 4.1.4(@types/node@26.1.1)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) packages: @@ -1866,6 +1928,42 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.170.18': + resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.15': + resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} + engines: {node: '>=20.19'} + + '@tanstack/router-generator@1.167.21': + resolution: {integrity: sha512-m3oXZyienj8owialdyoZ0txHQrnEx/Ra+D9kWtar5fC2cWZr5Pvxl86VY2mX5RRLC5QLKLeRGT1x4HV95wHVDQ==} + engines: {node: '>=20.19'} + + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + '@testing-library/jest-dom@6.9.1': resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} @@ -2150,8 +2248,8 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2413,6 +2511,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2469,6 +2571,9 @@ packages: axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2728,6 +2833,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2983,6 +3091,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} @@ -3656,6 +3768,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3827,6 +3943,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4262,11 +4382,6 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4633,10 +4748,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.17: resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} engines: {node: ^10 || ^12 || >=14} @@ -5117,6 +5228,16 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -5602,8 +5723,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} undici@7.25.0: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} @@ -5653,6 +5774,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + url-search-params-polyfill@8.2.5: + resolution: {integrity: sha512-FOEojW4XReTmtZOB7xqSHmJZhrNTmClhBriwLTmle4iA7bwuCo6ldSfbtsFSb8bTf3E0a3XpfonAdaur9vqq8A==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -5975,6 +6099,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6573,7 +6700,7 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -6587,7 +6714,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -6831,6 +6958,13 @@ snapshots: optionalDependencies: '@lynx-js/types': 3.7.0 + '@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + preact: '@hongzhiyuan/preact@10.28.0-fc4af453' + optionalDependencies: + '@lynx-js/types': 3.7.0 + '@lynx-js/rspeedy@0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0)': dependencies: '@lynx-js/cache-events-webpack-plugin': 0.0.3 @@ -7702,6 +7836,61 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/history@1.162.0': {} + + '@tanstack/react-router@1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-core': 1.171.15 + isbot: 5.2.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + + '@tanstack/router-core@1.171.15': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + '@tanstack/router-generator@1.167.21': + dependencies: + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.171.15 + '@tanstack/router-utils': 1.162.2 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.8.3 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.162.2': + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + ansis: 4.3.1 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.16 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.162.0': {} + '@testing-library/jest-dom@6.9.1': dependencies: '@adobe/css-tools': 4.4.4 @@ -8028,9 +8217,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@25.6.0': + '@types/node@26.1.1': dependencies: - undici-types: 7.19.2 + undici-types: 8.3.0 '@types/prop-types@15.7.15': {} @@ -8085,7 +8274,7 @@ snapshots: react: 19.2.5 unhead: 2.1.13 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@26.1.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8100,7 +8289,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@26.1.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -8116,7 +8305,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@26.1.1)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -8135,21 +8324,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) - '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -8342,6 +8531,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.3.1: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8406,6 +8597,15 @@ snapshots: transitivePeerDependencies: - debug + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + babel-jest@29.7.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -8674,6 +8874,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -8706,13 +8908,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)): + create-jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -8729,16 +8931,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-declaration-sorter@7.4.0(postcss@8.5.10): + css-declaration-sorter@7.4.0(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 css-minimizer-webpack-plugin@7.0.2(webpack@5.105.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 - cssnano: 7.1.5(postcss@8.5.10) + cssnano: 7.1.5(postcss@8.5.17) jest-worker: 29.7.0 - postcss: 8.5.10 + postcss: 8.5.17 schema-utils: 4.3.3 serialize-javascript: 6.0.2 webpack: 5.105.0 @@ -8767,49 +8969,49 @@ snapshots: cssesc@3.0.0: {} - cssnano-preset-default@7.0.13(postcss@8.5.10): + cssnano-preset-default@7.0.13(postcss@8.5.17): dependencies: browserslist: 4.28.2 - css-declaration-sorter: 7.4.0(postcss@8.5.10) - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 - postcss-calc: 10.1.1(postcss@8.5.10) - postcss-colormin: 7.0.8(postcss@8.5.10) - postcss-convert-values: 7.0.10(postcss@8.5.10) - postcss-discard-comments: 7.0.6(postcss@8.5.10) - postcss-discard-duplicates: 7.0.2(postcss@8.5.10) - postcss-discard-empty: 7.0.1(postcss@8.5.10) - postcss-discard-overridden: 7.0.1(postcss@8.5.10) - postcss-merge-longhand: 7.0.5(postcss@8.5.10) - postcss-merge-rules: 7.0.9(postcss@8.5.10) - postcss-minify-font-values: 7.0.1(postcss@8.5.10) - postcss-minify-gradients: 7.0.3(postcss@8.5.10) - postcss-minify-params: 7.0.7(postcss@8.5.10) - postcss-minify-selectors: 7.0.6(postcss@8.5.10) - postcss-normalize-charset: 7.0.1(postcss@8.5.10) - postcss-normalize-display-values: 7.0.1(postcss@8.5.10) - postcss-normalize-positions: 7.0.1(postcss@8.5.10) - postcss-normalize-repeat-style: 7.0.1(postcss@8.5.10) - postcss-normalize-string: 7.0.1(postcss@8.5.10) - postcss-normalize-timing-functions: 7.0.1(postcss@8.5.10) - postcss-normalize-unicode: 7.0.7(postcss@8.5.10) - postcss-normalize-url: 7.0.1(postcss@8.5.10) - postcss-normalize-whitespace: 7.0.1(postcss@8.5.10) - postcss-ordered-values: 7.0.2(postcss@8.5.10) - postcss-reduce-initial: 7.0.7(postcss@8.5.10) - postcss-reduce-transforms: 7.0.1(postcss@8.5.10) - postcss-svgo: 7.1.1(postcss@8.5.10) - postcss-unique-selectors: 7.0.5(postcss@8.5.10) - - cssnano-utils@5.0.1(postcss@8.5.10): - dependencies: - postcss: 8.5.10 - - cssnano@7.1.5(postcss@8.5.10): - dependencies: - cssnano-preset-default: 7.0.13(postcss@8.5.10) + css-declaration-sorter: 7.4.0(postcss@8.5.17) + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 + postcss-calc: 10.1.1(postcss@8.5.17) + postcss-colormin: 7.0.8(postcss@8.5.17) + postcss-convert-values: 7.0.10(postcss@8.5.17) + postcss-discard-comments: 7.0.6(postcss@8.5.17) + postcss-discard-duplicates: 7.0.2(postcss@8.5.17) + postcss-discard-empty: 7.0.1(postcss@8.5.17) + postcss-discard-overridden: 7.0.1(postcss@8.5.17) + postcss-merge-longhand: 7.0.5(postcss@8.5.17) + postcss-merge-rules: 7.0.9(postcss@8.5.17) + postcss-minify-font-values: 7.0.1(postcss@8.5.17) + postcss-minify-gradients: 7.0.3(postcss@8.5.17) + postcss-minify-params: 7.0.7(postcss@8.5.17) + postcss-minify-selectors: 7.0.6(postcss@8.5.17) + postcss-normalize-charset: 7.0.1(postcss@8.5.17) + postcss-normalize-display-values: 7.0.1(postcss@8.5.17) + postcss-normalize-positions: 7.0.1(postcss@8.5.17) + postcss-normalize-repeat-style: 7.0.1(postcss@8.5.17) + postcss-normalize-string: 7.0.1(postcss@8.5.17) + postcss-normalize-timing-functions: 7.0.1(postcss@8.5.17) + postcss-normalize-unicode: 7.0.7(postcss@8.5.17) + postcss-normalize-url: 7.0.1(postcss@8.5.17) + postcss-normalize-whitespace: 7.0.1(postcss@8.5.17) + postcss-ordered-values: 7.0.2(postcss@8.5.17) + postcss-reduce-initial: 7.0.7(postcss@8.5.17) + postcss-reduce-transforms: 7.0.1(postcss@8.5.17) + postcss-svgo: 7.1.1(postcss@8.5.17) + postcss-unique-selectors: 7.0.5(postcss@8.5.17) + + cssnano-utils@5.0.1(postcss@8.5.17): + dependencies: + postcss: 8.5.17 + + cssnano@7.1.5(postcss@8.5.17): + dependencies: + cssnano-preset-default: 7.0.13(postcss@8.5.17) lilconfig: 3.1.3 - postcss: 8.5.10 + postcss: 8.5.17 csso@5.0.5: dependencies: @@ -8934,6 +9136,8 @@ snapshots: diff@4.0.4: {} + diff@8.0.4: {} + dom-accessibility-api@0.6.3: {} dom-serializer@2.0.0: @@ -9781,6 +9985,8 @@ snapshots: isarray@2.0.5: {} + isbot@5.2.1: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -9889,16 +10095,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)): + jest-cli@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + create-jest: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -9939,7 +10145,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)): + jest-config@29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 @@ -9965,12 +10171,12 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.19.17 - ts-node: 10.9.2(@types/node@25.6.0)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@26.1.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)): + jest-config@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 @@ -9995,8 +10201,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 25.6.0 - ts-node: 10.9.2(@types/node@25.6.0)(typescript@5.9.3) + '@types/node': 26.1.1 + ts-node: 10.9.2(@types/node@26.1.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -10211,7 +10417,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.19.17 + '@types/node': 26.1.1 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -10234,12 +10440,12 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)): + jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + jest-cli: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -10248,6 +10454,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -10950,8 +11158,6 @@ snapshots: mustache@4.2.0: {} - nanoid@3.3.11: {} - nanoid@3.3.16: {} natural-compare@1.4.0: {} @@ -11132,142 +11338,142 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-calc@10.1.1(postcss@8.5.10): + postcss-calc@10.1.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - postcss-colormin@7.0.8(postcss@8.5.10): + postcss-colormin@7.0.8(postcss@8.5.17): dependencies: '@colordx/core': 5.0.3 browserslist: 4.28.2 caniuse-api: 3.0.0 - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-convert-values@7.0.10(postcss@8.5.10): + postcss-convert-values@7.0.10(postcss@8.5.17): dependencies: browserslist: 4.28.2 - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-discard-comments@7.0.6(postcss@8.5.10): + postcss-discard-comments@7.0.6(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 - postcss-discard-duplicates@7.0.2(postcss@8.5.10): + postcss-discard-duplicates@7.0.2(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-discard-empty@7.0.1(postcss@8.5.10): + postcss-discard-empty@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-discard-overridden@7.0.1(postcss@8.5.10): + postcss-discard-overridden@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-merge-longhand@7.0.5(postcss@8.5.10): + postcss-merge-longhand@7.0.5(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - stylehacks: 7.0.9(postcss@8.5.10) + stylehacks: 7.0.9(postcss@8.5.17) - postcss-merge-rules@7.0.9(postcss@8.5.10): + postcss-merge-rules@7.0.9(postcss@8.5.17): dependencies: browserslist: 4.28.2 caniuse-api: 3.0.0 - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-selector-parser: 7.1.1 - postcss-minify-font-values@7.0.1(postcss@8.5.10): + postcss-minify-font-values@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-minify-gradients@7.0.3(postcss@8.5.10): + postcss-minify-gradients@7.0.3(postcss@8.5.17): dependencies: '@colordx/core': 5.0.3 - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-minify-params@7.0.7(postcss@8.5.10): + postcss-minify-params@7.0.7(postcss@8.5.17): dependencies: browserslist: 4.28.2 - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-minify-selectors@7.0.6(postcss@8.5.10): + postcss-minify-selectors@7.0.6(postcss@8.5.17): dependencies: cssesc: 3.0.0 - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 - postcss-normalize-charset@7.0.1(postcss@8.5.10): + postcss-normalize-charset@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-normalize-display-values@7.0.1(postcss@8.5.10): + postcss-normalize-display-values@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-positions@7.0.1(postcss@8.5.10): + postcss-normalize-positions@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@7.0.1(postcss@8.5.10): + postcss-normalize-repeat-style@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-string@7.0.1(postcss@8.5.10): + postcss-normalize-string@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@7.0.1(postcss@8.5.10): + postcss-normalize-timing-functions@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@7.0.7(postcss@8.5.10): + postcss-normalize-unicode@7.0.7(postcss@8.5.17): dependencies: browserslist: 4.28.2 - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-url@7.0.1(postcss@8.5.10): + postcss-normalize-url@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@7.0.1(postcss@8.5.10): + postcss-normalize-whitespace@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-ordered-values@7.0.2(postcss@8.5.10): + postcss-ordered-values@7.0.2(postcss@8.5.17): dependencies: - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-reduce-initial@7.0.7(postcss@8.5.10): + postcss-reduce-initial@7.0.7(postcss@8.5.17): dependencies: browserslist: 4.28.2 caniuse-api: 3.0.0 - postcss: 8.5.10 + postcss: 8.5.17 - postcss-reduce-transforms@7.0.1(postcss@8.5.10): + postcss-reduce-transforms@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 postcss-selector-parser@7.1.1: @@ -11275,25 +11481,19 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-svgo@7.1.1(postcss@8.5.10): + postcss-svgo@7.1.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 svgo: 4.0.1 - postcss-unique-selectors@7.0.5(postcss@8.5.10): + postcss-unique-selectors@7.0.5(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 postcss-value-parser@4.2.0: {} - postcss@8.5.10: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.17: dependencies: nanoid: 3.3.16 @@ -11870,6 +12070,12 @@ snapshots: dependencies: randombytes: 2.1.0 + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -12016,7 +12222,7 @@ snapshots: space-separated-tokens@2.0.2: {} - sparkling-app-cli@2.1.0-rc.12(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3)(webpack@5.105.0): + sparkling-app-cli@2.1.0-rc.12(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/node@26.1.1)(typescript@5.9.3)(webpack@5.105.0): dependencies: '@lynx-js/rspeedy': 0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0) chalk: 4.1.2 @@ -12024,7 +12230,7 @@ snapshots: fast-glob: 3.3.3 fs-extra: 11.3.4 semver: 7.7.4 - ts-node: 10.9.2(@types/node@25.6.0)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@26.1.1)(typescript@5.9.3) transitivePeerDependencies: - '@parcel/css' - '@rspack/core' @@ -12151,10 +12357,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylehacks@7.0.9(postcss@8.5.10): + stylehacks@7.0.9(postcss@8.5.17): dependencies: browserslist: 4.28.2 - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 supports-color@7.2.0: @@ -12307,12 +12513,12 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 29.7.0 - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + jest: 29.7.0(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -12345,14 +12551,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3): + ts-node@10.9.2(@types/node@26.1.1)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.6.0 + '@types/node': 26.1.1 acorn: 8.16.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -12440,7 +12646,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.19.2: {} + undici-types@8.3.0: {} undici@7.25.0: {} @@ -12505,6 +12711,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + url-search-params-polyfill@8.2.5: {} + use-sync-external-store@1.6.0(react@19.2.5): dependencies: react: 19.2.5 @@ -12542,13 +12750,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + vite-node@3.2.4(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -12563,28 +12771,28 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.17 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 sass: 1.100.0 sass-embedded: 1.100.0 terser: 5.46.1 yaml: 2.8.3 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@26.1.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -12602,12 +12810,12 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 - '@types/node': 25.6.0 + '@types/node': 26.1.1 jsdom: 26.1.0 transitivePeerDependencies: - jiti @@ -12623,10 +12831,10 @@ snapshots: - tsx - yaml - vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)): + vitest@4.1.4(@types/node@26.1.1)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -12643,10 +12851,10 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@26.1.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) jsdom: 28.1.0 transitivePeerDependencies: @@ -12835,4 +13043,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/scripts/coverage-ts.mjs b/scripts/coverage-ts.mjs index aafb844a..371de6a2 100644 --- a/scripts/coverage-ts.mjs +++ b/scripts/coverage-ts.mjs @@ -33,6 +33,16 @@ const targets = [ filter: 'sparkling-navigation', type: 'jest', }, + { + name: 'sparkling-router', + filter: 'sparkling-router', + type: 'jest', + }, + { + name: 'sparkling-router-plugin', + filter: 'sparkling-router-plugin', + type: 'jest', + }, { name: 'sparkling-storage', filter: 'sparkling-storage', @@ -65,9 +75,15 @@ const runCommand = (cmd, args) => }); console.log('\n[coverage:ts] Building shared TypeScript workspace deps'); -const sharedPrebuild = runCommand('pnpm', ['--filter', 'sparkling-method', 'build']); +const sharedPrebuild = runCommand('pnpm', [ + '--filter', + 'sparkling-method', + '--filter', + 'sparkling-navigation', + 'build', +]); if (sharedPrebuild.status !== 0) { - failures.push('sparkling-method (prebuild)'); + failures.push('shared TypeScript deps (prebuild)'); } for (const target of targets) { diff --git a/scripts/update-all-version.sh b/scripts/update-all-version.sh index 52bdb1e6..999f2880 100755 --- a/scripts/update-all-version.sh +++ b/scripts/update-all-version.sh @@ -110,6 +110,8 @@ declare -a TYPESCRIPT_FILES=( "packages/sparkling-sdk/package.json" "packages/sparkling-method/package.json" "packages/sparkling-types/package.json" + "packages/sparkling-router/package.json" + "packages/sparkling-router-plugin/package.json" "packages/methods/sparkling-navigation/package.json" "packages/methods/sparkling-media/package.json" "packages/methods/sparkling-storage/package.json" diff --git a/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingApplication.kt b/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingApplication.kt index a4b3bbc9..621ab9ae 100644 --- a/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingApplication.kt +++ b/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingApplication.kt @@ -18,6 +18,7 @@ import com.tiktok.sparkling.method.registry.core.IDLBridgeMethod import com.tiktok.sparkling.method.registry.core.SparklingBridgeManager import com.tiktok.sparkling.method.router.close.RouterCloseMethod import com.tiktok.sparkling.method.router.open.RouterOpenMethod +import com.tiktok.sparkling.method.router.stack.RouterStackMethod import com.tiktok.sparkling.method.router.utils.RouterProvider import com.example.sparkling.go.BuiltinTemplateProvider @@ -60,6 +61,7 @@ class SparklingApplication : Application() { if (!autolinked) { SparklingBridgeManager.registerIDLMethod(RouterOpenMethod::class.java) SparklingBridgeManager.registerIDLMethod(RouterCloseMethod::class.java) + SparklingBridgeManager.registerIDLMethod(RouterStackMethod::class.java) } RouterProvider.hostRouterDepend = SparklingHostRouterDepend() } diff --git a/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingAutolink.kt b/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingAutolink.kt index d82faa0e..fd7ec1f8 100644 --- a/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingAutolink.kt +++ b/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingAutolink.kt @@ -18,6 +18,7 @@ object SparklingAutolink { listOf( "com.tiktok.sparkling.method.router.open.RouterOpenMethod", "com.tiktok.sparkling.method.router.close.RouterCloseMethod", + "com.tiktok.sparkling.method.router.stack.RouterStackMethod", ), ), ) diff --git a/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingHostRouterDepend.kt b/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingHostRouterDepend.kt index 414797f2..83a951b1 100644 --- a/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingHostRouterDepend.kt +++ b/template/sparkling-app-template/android/app/src/main/java/com/example/sparkling/go/SparklingHostRouterDepend.kt @@ -6,10 +6,15 @@ package com.example.sparkling.go import android.content.Context import com.tiktok.sparkling.Sparkling import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.SparklingNavigationStack +import com.tiktok.sparkling.SparklingNavigationTarget import com.tiktok.sparkling.hybridkit.service.HybridActivityStackManager import com.tiktok.sparkling.method.registry.core.BridgePlatformType import com.tiktok.sparkling.method.registry.core.IBridgeContext import com.tiktok.sparkling.method.router.utils.IHostRouterDepend +import com.tiktok.sparkling.method.router.utils.RouterStackCommand +import com.tiktok.sparkling.method.router.utils.RouterStackResult +import com.tiktok.sparkling.method.router.utils.RouterStackTarget class SparklingHostRouterDepend : IHostRouterDepend { override fun openScheme( @@ -21,8 +26,9 @@ class SparklingHostRouterDepend : IHostRouterDepend { ): Boolean { val sparklingContext = SparklingContext() sparklingContext.scheme = scheme - context?.let { Sparkling.Companion.build(it, sparklingContext).navigate() } - return true + return context?.let { + Sparkling.Companion.build(it, sparklingContext).navigate() + } ?: false } override fun closeView( @@ -31,12 +37,97 @@ class SparklingHostRouterDepend : IHostRouterDepend { containerID: String?, animated: Boolean?, ): Boolean { + if (!containerID.isNullOrBlank()) { + return SparklingNavigationStack.pop(containerID).success + } + val currentId = bridgeContext?.containerID + if (!currentId.isNullOrBlank() && SparklingNavigationStack.pop(currentId).success) { + return true + } val ownerActivity = bridgeContext?.ownerActivity if (ownerActivity != null) { ownerActivity.finish() + return true } else { - HybridActivityStackManager.getTopActivity()?.finish() + val top = HybridActivityStackManager.getTopActivity() ?: return false + top.finish() + return true } - return true } + + override fun executeStackCommand( + bridgeContext: IBridgeContext?, + command: RouterStackCommand, + context: Context?, + ): RouterStackResult? { + val appContext = context ?: bridgeContext?.context + val response = + when (command.command) { + "getState" -> null + "push" -> { + val target = command.target ?: return RouterStackResult(false, "push requires a target") + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.push( + hostContext, + target.toNative(), + usePrefetched = command.usePrefetched, + sourceEntryId = bridgeContext?.containerID, + ) + } + "pop" -> + SparklingNavigationStack.pop( + bridgeContext?.containerID, + result = command.result, + ) + "popTo" -> { + val entryId = command.entryId ?: return RouterStackResult(false, "popTo requires entryId") + SparklingNavigationStack.popTo(entryId) + } + "replace" -> { + val target = command.target ?: return RouterStackResult(false, "replace requires a target") + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.replace( + hostContext, + bridgeContext?.containerID, + target.toNative(), + ) + } + "reset" -> { + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.reset( + hostContext, + command.entries.map { it.toNative() }, + ) + } + "prefetch" -> { + val target = command.target ?: return RouterStackResult(false, "prefetch requires a target") + val hostContext = appContext ?: return RouterStackResult(false, "Context not available") + SparklingNavigationStack.prefetch(hostContext, target.toNative()) + } + "syncOwnLocation" -> { + val target = command.target ?: return RouterStackResult(false, "syncOwnLocation requires a location") + SparklingNavigationStack.syncOwnLocation( + bridgeContext?.containerID, + target.path, + target.search, + ) + } + else -> return RouterStackResult(false, "Unknown command: ${command.command}") + } + return RouterStackResult( + success = response?.success ?: true, + message = response?.message ?: "ok", + entryId = response?.entryId, + state = SparklingNavigationStack.stateMap(), + ) + } + + private fun RouterStackTarget.toNative(): SparklingNavigationTarget = + SparklingNavigationTarget( + path = path, + search = search, + bundle = bundle, + scheme = scheme, + presentation = presentation, + ) } diff --git a/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift b/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift index a430fd37..f37bf527 100644 --- a/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift +++ b/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/RouterServiceImpl.swift @@ -9,10 +9,21 @@ import Sparkling_Router class RouterServiceImpl: RouterService { func closeContainer(withParams params: Sparkling_Router.CloseMethodParamModel, completion: @escaping SparklingMethod.PipeMethod.CompletionBlock) { - if SPKRouter.close(container: params.context?.pipeContainer) { - completion(.succeeded(), nil) - } else { - completion(.failed(message: "Unable to close the container"), nil) + DispatchQueue.main.async { + let success: Bool + if let containerID = params.containerID, !containerID.isEmpty { + success = SPKRouter.close( + containerID: containerID, + animated: params.animated + ) + } else { + success = SPKRouter.close(container: params.context?.pipeContainer) + } + if success { + completion(.succeeded(), nil) + } else { + completion(.failed(message: "Unable to close the container"), nil) + } } } @@ -22,7 +33,12 @@ class RouterServiceImpl: RouterService { DispatchQueue.main.async { func openWithRouter(completionHandler: ((Bool) -> Void)? = nil) { - if let (_, success) = SPKRouter.open(withURL: urlString, context: context), success { + if let (_, success) = SPKRouter.open( + withURL: urlString, + context: context, + presentation: "push", + animated: params.animated + ), success { completionHandler?(true) completion(.succeeded(), nil) } else { @@ -40,7 +56,7 @@ class RouterServiceImpl: RouterService { } } else { if params.replace == true && params.replaceType == "alwaysCloseBeforeOpen" { - if SPKRouter.close(container: params.context?.pipeContainer) { + if !SPKRouter.close(container: params.context?.pipeContainer) { print("Unable to close the container") } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -49,7 +65,7 @@ class RouterServiceImpl: RouterService { } else if params.replace == true { openWithRouter { success in if params.replaceType == "alwaysCloseAfterOpen" || (params.replaceType == "onlyCloseAfterOpenSucceed" && success) { - if SPKRouter.close(container: params.context?.pipeContainer) { + if !SPKRouter.close(container: params.context?.pipeContainer) { print("Unable to close the container") } } diff --git a/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/RouterStackServiceImpl.swift b/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/RouterStackServiceImpl.swift new file mode 100644 index 00000000..e3a2c5ca --- /dev/null +++ b/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/RouterStackServiceImpl.swift @@ -0,0 +1,164 @@ +// Copyright 2026 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 Foundation +import Sparkling +import SparklingMethod +import Sparkling_Router + +extension RouterServiceImpl: RouterStackService { + func performStackCommand( + withParams params: Sparkling_Router.StackMethodParamModel, + completion: @escaping SparklingMethod.PipeMethod.CompletionBlock + ) { + DispatchQueue.main.async { + let stack = SPKNavigationStack.shared + let sourceID = params.context?.pipeContainer?.spk_containerID + let resultModel = StackMethodResultModel() + + func finish(_ result: SPKNavigationResult) { + resultModel.entryId = result.entryId + resultModel.state = stack.stateDictionary as NSDictionary + if result.success { + completion(.succeeded(), resultModel) + } else { + completion(.failed(message: result.message), nil) + } + } + + switch params.command { + case "getState": + resultModel.state = stack.stateDictionary as NSDictionary + completion(.succeeded(), resultModel) + case "push": + guard let target = self.stackTarget(from: params) else { + completion(.invalidParameter(message: "push requires scheme and path"), nil) + return + } + let (_, result) = stack.push( + target, + context: SPKContext(), + animated: params.animated, + usePrefetched: params.usePrefetched, + sourceEntryId: sourceID + ) + finish(result) + case "pop": + guard let sourceID = sourceID, !sourceID.isEmpty else { + completion(.invalidParameter(message: "pop requires a source container"), nil) + return + } + finish(stack.pop( + entryId: sourceID, + result: params.result, + animated: params.animated + )) + case "popTo": + guard let entryId = params.entryId, !entryId.isEmpty else { + completion(.invalidParameter(message: "popTo requires entryId"), nil) + return + } + finish(stack.popTo(entryId: entryId, animated: params.animated)) + case "replace": + guard let sourceID = sourceID, !sourceID.isEmpty else { + completion(.invalidParameter(message: "replace requires a source container"), nil) + return + } + guard let target = self.stackTarget(from: params) else { + completion(.invalidParameter(message: "replace requires scheme and path"), nil) + return + } + finish(stack.replace( + entryId: sourceID, + target: target, + context: SPKContext(), + animated: params.animated + )) + case "reset": + guard let rawEntries = params.entries as? [[String: Any]], + !rawEntries.isEmpty + else { + completion(.invalidParameter(message: "reset requires entries"), nil) + return + } + let targets = rawEntries.compactMap { self.stackTarget(from: $0) } + guard targets.count == rawEntries.count else { + completion(.invalidParameter(message: "reset contains an invalid entry"), nil) + return + } + finish(stack.reset( + targets: targets, + context: SPKContext(), + animated: params.animated + )) + case "prefetch": + guard let target = self.stackTarget(from: params) else { + completion(.invalidParameter(message: "prefetch requires scheme and path"), nil) + return + } + finish(stack.prefetch(target, context: SPKContext())) + case "syncOwnLocation": + guard let sourceID = sourceID, !sourceID.isEmpty else { + completion( + .invalidParameter(message: "syncOwnLocation requires a source container"), + nil + ) + return + } + finish(stack.syncOwnLocation( + entryId: sourceID, + path: params.path ?? "/", + search: self.stackStringDictionary(params.search) + )) + default: + completion( + .invalidParameter(message: "Unknown stack command: \(params.command ?? "")"), + nil + ) + } + } + } + + private func stackTarget( + from params: Sparkling_Router.StackMethodParamModel + ) -> SPKNavigationTarget? { + guard let scheme = params.scheme, !scheme.isEmpty, + let path = params.path, !path.isEmpty + else { + return nil + } + return SPKNavigationTarget( + path: path, + search: stackStringDictionary(params.search), + bundle: params.bundle ?? "", + scheme: scheme, + presentation: params.presentation ?? "push" + ) + } + + private func stackTarget(from dictionary: [String: Any]) -> SPKNavigationTarget? { + guard let scheme = dictionary["scheme"] as? String, !scheme.isEmpty, + let path = dictionary["path"] as? String, !path.isEmpty + else { + return nil + } + return SPKNavigationTarget( + path: path, + search: stackStringDictionary(dictionary["search"] as? NSDictionary), + bundle: dictionary["bundle"] as? String ?? "", + scheme: scheme, + presentation: dictionary["presentation"] as? String ?? "push" + ) + } + + private func stackStringDictionary( + _ dictionary: NSDictionary? + ) -> [String: String] { + var result: [String: String] = [:] + dictionary?.forEach { key, value in + result[String(describing: key)] = String(describing: value) + } + return result + } +} diff --git a/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift b/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift index afb7e5b7..ef312aec 100644 --- a/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift +++ b/template/sparkling-app-template/ios/SparklingGo/SparklingGo/MethodServices/SPKServiceRegistrar.swift @@ -18,6 +18,9 @@ enum SPKServiceRegister { DIProviderRegistry.provider.pipeShared().register(RouterService.self) { RouterServiceImpl() } + DIProviderRegistry.provider.pipeShared().register(RouterStackService.self) { + RouterServiceImpl() + } #if canImport(Sparkling_Storage) DIProviderRegistry.provider.pipeShared().register(StorageService.self) { StorageServiceImpl()