Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.

This commit is contained in:
2026-07-22 07:04:39 +00:00
parent bfe364c57d
commit 6aed7606ad
48 changed files with 5454 additions and 84 deletions

View File

@@ -0,0 +1,44 @@
import { reactive, type InjectionKey, type Ref } from "vue";
export interface CompatibilityLabEvent {
id: number;
timestamp: string;
source: string;
hook: string;
detail?: string;
}
export interface CompatibilityLabContext {
source: string;
routeLabel: Readonly<Ref<string>>;
}
export const demoAppValueKey: InjectionKey<string> = Symbol("demo-app-value");
export const compatibilityLabContextKey: InjectionKey<CompatibilityLabContext> =
Symbol("compatibility-lab-context");
let eventSequence = 0;
const startedAt = performance.now();
export const compatibilityLab = reactive({
events: [] as CompatibilityLabEvent[],
});
export function recordCompatibilityEvent(
source: string,
hook: string,
detail?: string,
) {
compatibilityLab.events.unshift({
id: ++eventSequence,
timestamp: `${(performance.now() - startedAt).toFixed(0)} ms`,
source,
hook,
detail,
});
if (compatibilityLab.events.length > 120) compatibilityLab.events.splice(120);
}
export function clearCompatibilityEvents() {
compatibilityLab.events.splice(0);
}

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { inject } from "vue";
import { useRoute } from "vue-router";
import {
compatibilityLabContextKey,
demoAppValueKey,
recordCompatibilityEvent,
} from "../compatibility-lab";
const props = defineProps<{ requestId: number }>();
const route = useRoute();
const appValue = inject(demoAppValueKey, "missing app injection");
const labContext = inject(compatibilityLabContextKey);
recordCompatibilityEvent(
"Suspense",
"async setup started",
`request ${props.requestId}`,
);
await new Promise((resolve) => window.setTimeout(resolve, 750));
recordCompatibilityEvent(
"Suspense",
"async setup resolved",
`request ${props.requestId}`,
);
</script>
<template>
<article
class="compat-probe compat-probe--resolved"
data-testid="compat-suspense-ready"
>
<span class="compat-probe__badge">Suspense resolved</span>
<strong>Async request {{ requestId }} complete</strong>
<p>{{ appValue }}</p>
<p>{{ labContext?.routeLabel.value }}</p>
<p>{{ route.fullPath }}</p>
</article>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import {
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onMounted,
onUnmounted,
onUpdated,
ref,
} from "vue";
import { useRoute } from "vue-router";
import { recordCompatibilityEvent } from "../compatibility-lab";
const props = defineProps<{
instanceName: string;
revision: number;
}>();
const route = useRoute();
const localCount = ref(0);
const record = (hook: string) =>
recordCompatibilityEvent(props.instanceName, hook, route.fullPath);
onBeforeMount(() => record("onBeforeMount"));
onMounted(() => record("onMounted"));
onBeforeUpdate(() => record("onBeforeUpdate"));
onUpdated(() => record("onUpdated"));
onBeforeUnmount(() => record("onBeforeUnmount"));
onUnmounted(() => record("onUnmounted"));
</script>
<template>
<article class="compat-probe" data-testid="composition-lifecycle-probe">
<span class="compat-probe__badge">Composition API</span>
<strong>{{ instanceName }}</strong>
<p><code>useRoute()</code>: {{ route.fullPath }}</p>
<p>Revision {{ revision }} · local count {{ localCount }}</p>
<button type="button" @click="localCount += 1">
Increment local state
</button>
</article>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { useRoute } from "vue-router";
import {
compatibilityLabContextKey,
demoAppValueKey,
} from "../compatibility-lab";
defineProps<{ location: "route tree" | "teleport" }>();
const route = useRoute();
const appValue = inject(demoAppValueKey, "missing app injection");
const labContext = inject(compatibilityLabContextKey);
const labValue = computed(
() => labContext?.routeLabel.value ?? "missing page injection",
);
</script>
<template>
<article
class="compat-probe"
:data-testid="`inject-probe-${location.replace(' ', '-')}`"
>
<span class="compat-probe__badge">provide / inject · {{ location }}</span>
<strong>{{ appValue }}</strong>
<p>Page injection: {{ labValue }}</p>
<p>Scoped route: {{ route.fullPath }}</p>
</article>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import {
onActivated,
onBeforeMount,
onBeforeUnmount,
onDeactivated,
onMounted,
onUnmounted,
ref,
} from "vue";
import { recordCompatibilityEvent } from "../compatibility-lab";
const props = defineProps<{ name: string }>();
const count = ref(0);
const record = (hook: string) =>
recordCompatibilityEvent(`KeepAlive ${props.name}`, hook);
onBeforeMount(() => record("onBeforeMount"));
onMounted(() => record("onMounted"));
onActivated(() => record("onActivated"));
onDeactivated(() => record("onDeactivated"));
onBeforeUnmount(() => record("onBeforeUnmount"));
onUnmounted(() => record("onUnmounted"));
</script>
<template>
<article
class="compat-probe compat-probe--keepalive"
:data-testid="`keep-alive-${name}`"
>
<span class="compat-probe__badge">Kept instance {{ name }}</span>
<strong>Counter: {{ count }}</strong>
<p>Increment, switch instances, then return to verify preserved state.</p>
<button type="button" @click="count += 1">Increment {{ name }}</button>
</article>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { defineComponent } from "vue";
import type { RouteLocationNormalizedLoaded } from "vue-router";
import { recordCompatibilityEvent } from "../compatibility-lab";
export default defineComponent({
name: "CompatibilityOptionsProbe",
props: {
instanceName: { type: String, required: true },
revision: { type: Number, required: true },
},
data: () => ({ localCount: 0 }),
beforeCreate() {
recordCompatibilityEvent(this.instanceName, "beforeCreate");
},
created() {
recordCompatibilityEvent(this.instanceName, "created", this.routePath());
},
beforeMount() {
recordCompatibilityEvent(this.instanceName, "beforeMount");
},
mounted() {
recordCompatibilityEvent(this.instanceName, "mounted");
},
beforeUpdate() {
recordCompatibilityEvent(this.instanceName, "beforeUpdate");
},
updated() {
recordCompatibilityEvent(this.instanceName, "updated");
},
beforeUnmount() {
recordCompatibilityEvent(this.instanceName, "beforeUnmount");
},
unmounted() {
recordCompatibilityEvent(this.instanceName, "unmounted");
},
methods: {
routePath() {
return (this as unknown as { $route: RouteLocationNormalizedLoaded })
.$route.fullPath;
},
},
});
</script>
<template>
<article class="compat-probe" data-testid="options-lifecycle-probe">
<span class="compat-probe__badge">Options API</span>
<strong>{{ instanceName }}</strong>
<p><code>$route</code>: {{ routePath() }}</p>
<p>Revision {{ revision }} · local count {{ localCount }}</p>
<button type="button" @click="localCount += 1">
Increment local state
</button>
</article>
</template>

View File

@@ -3,6 +3,7 @@ import { createNativeRouter } from "@native-vue-router/core";
import { createCapacitorAdapter } from "@native-vue-router/capacitor";
import { createElectronRendererAdapter } from "@native-vue-router/electron";
import App from "./App.vue";
import { demoAppValueKey } from "./compatibility-lab";
import { createPwaAdapter } from "./pwa";
import { router } from "./router";
import "./style.css";
@@ -27,6 +28,7 @@ const nativeRouter = createNativeRouter({
const app = createApp(App);
app.use(router);
app.use(nativeRouter);
app.provide(demoAppValueKey, "Injected from the demo application root");
await router.isReady();
app.mount("#app");

View File

@@ -104,6 +104,31 @@ const routes: RouteRecordRaw[] = [
native: { presentation: "push", parent: "/profile", gesture: "edge" },
},
},
{
path: "/profile/vue-lab/:sample",
name: "vue-compatibility",
component: () => import("./views/VueCompatibilityView.vue"),
meta: {
native: { presentation: "push", parent: "/profile", gesture: "edge" },
},
},
{
path: "/profile/vue-lab/:sample/away",
name: "vue-compatibility-away",
component: () => import("./views/VueCompatibilityAwayView.vue"),
meta: {
native: {
presentation: "push",
parent: (route) => ({
name: "vue-compatibility",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
}),
gesture: "edge",
},
},
},
];
export const router = createRouter({

View File

@@ -28,7 +28,8 @@ html,
body,
#app {
width: 100%;
height: 100%;
/* Commenting this out for now. Its causing a black bar at the bottom of the screen on iOS */
/* height: 100%; */
margin: 0;
overflow: hidden;
overscroll-behavior: none;
@@ -65,7 +66,7 @@ html[data-pwa-edge-guard="active"] body {
.app-frame {
position: relative;
width: 100%;
height: 100dvh;
height: 100vh;
max-width: 740px;
margin: 0 auto;
overflow: hidden;
@@ -662,18 +663,19 @@ html[data-pwa-edge-guard="active"] body {
font-size: 21px;
}
.sheet-screen {
border-radius: 22px 22px 0 0;
.compose-sheet {
--nvr-sheet-background: #0b0d12;
--nvr-sheet-backdrop: rgba(0, 0, 0, 0.42);
}
.sheet-handle {
position: sticky;
z-index: 12;
top: 8px;
width: 38px;
height: 5px;
margin: 8px auto 0;
border-radius: 4px;
background: #4e5360;
.compose-sheet-content {
min-height: 0;
background:
radial-gradient(
circle at 88% 4%,
rgba(124, 92, 255, 0.09),
transparent 24%
),
#0b0d12;
}
.sheet-header {
display: grid;
@@ -685,12 +687,23 @@ html[data-pwa-edge-guard="active"] body {
margin: 0;
font-size: 16px;
}
.sheet-header small {
display: block;
margin-top: 2px;
color: var(--muted);
text-align: center;
font-size: 10px;
}
.sheet-header button {
justify-self: start;
border: 0;
color: #9b88ff;
background: transparent;
}
.sheet-header button:last-child {
justify-self: end;
font-size: 11px;
}
.compose-search {
margin: 0 16px 14px;
}
@@ -884,6 +897,226 @@ html[data-pwa-edge-guard="active"] body {
background: #ff6c76;
box-shadow: 0 0 9px #ff6c76;
}
.compatibility-screen {
padding-bottom: calc(34px + env(safe-area-inset-bottom));
}
.compat-header-state {
padding: 5px 8px;
border: 1px solid rgba(255, 194, 92, 0.28);
border-radius: 999px;
color: #ffc86c;
background: rgba(255, 194, 92, 0.08);
font-size: 9px;
font-weight: 800;
text-transform: uppercase;
}
.compat-header-state.active {
border-color: rgba(61, 217, 170, 0.3);
color: #6ce5bf;
background: rgba(61, 217, 170, 0.08);
}
.compat-intro > span {
width: 64px;
font-size: 15px;
}
.compat-route-state code {
max-width: 52%;
overflow-wrap: anywhere;
color: #b9adff;
font-size: 11px;
text-align: right;
}
.compat-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0 16px 18px;
}
.compat-controls--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.compat-controls button,
.compat-inline-actions button,
.compat-probe button,
.compat-event-heading button {
min-height: 42px;
border: 1px solid rgba(124, 92, 255, 0.25);
border-radius: 12px;
color: #c7beff;
background: rgba(124, 92, 255, 0.1);
font-size: 11px;
font-weight: 750;
}
.settings-group > .compat-inline-actions {
min-height: auto;
justify-content: flex-start;
}
.compat-inline-actions button {
flex: 1;
}
.settings-group > .compat-probe-grid {
display: grid;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: 10px;
padding: 12px;
}
.compat-probe {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin: 10px 12px;
padding: 13px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 15px;
background: rgba(255, 255, 255, 0.025);
}
.compat-probe-grid .compat-probe {
margin: 0;
}
.compat-probe__badge {
color: #a998ff;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.compat-probe strong {
font-size: 13px;
}
.compat-probe p {
margin: 0;
color: var(--muted);
font-size: 10px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.compat-probe code {
color: #b9adff;
}
.compat-probe button {
width: 100%;
margin-top: auto;
}
.compat-probe--keepalive {
border-color: rgba(61, 217, 170, 0.16);
background: rgba(61, 217, 170, 0.045);
}
.compat-probe--resolved {
border-color: rgba(61, 217, 170, 0.2);
}
.compat-probe--loading {
min-height: 96px;
flex-direction: row;
align-items: center;
}
.compat-transition-card {
margin: 10px 12px;
padding: 22px 14px;
border: 1px solid rgba(255, 194, 92, 0.22);
border-radius: 15px;
color: #ffd28a;
background: rgba(255, 194, 92, 0.07);
font-size: 12px;
font-weight: 750;
text-align: center;
}
.compat-fade-enter-active,
.compat-fade-leave-active {
transition:
opacity 220ms ease,
transform 220ms ease;
}
.compat-fade-enter-from,
.compat-fade-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
.compat-teleport {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(2, 3, 6, 0.74);
backdrop-filter: blur(12px);
}
.compat-teleport > section {
position: relative;
width: min(440px, 100%);
padding: 20px;
border: 1px solid rgba(169, 152, 255, 0.32);
border-radius: 22px;
background: #141720;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58);
}
.compat-teleport > section > button {
position: absolute;
top: 10px;
right: 10px;
width: 34px;
height: 34px;
border: 0;
border-radius: 50%;
color: #fff;
background: rgba(255, 255, 255, 0.08);
font-size: 20px;
}
.compat-teleport h2 {
margin: 0 44px 15px 0;
font-size: 18px;
}
.compat-teleport .compat-probe {
margin: 0;
}
.settings-group > .compat-event-heading {
min-height: 66px;
}
.compat-event-heading button {
min-height: 34px;
padding: 0 12px;
}
.compat-event-log {
max-height: 360px;
margin: 0;
padding: 0;
overflow-y: auto;
border-top: 1px solid var(--line);
list-style: none;
}
.compat-event-log li {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
padding: 9px 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.045);
}
.compat-event-log time,
.compat-event-log small {
color: #686e7b;
font-size: 9px;
}
.compat-event-log span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.compat-event-log strong {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.compat-event-log code {
color: #afa1ff;
font-size: 10px;
}
.empty-state {
display: grid;
place-items: center;
@@ -926,6 +1159,19 @@ html[data-pwa-edge-guard="active"] body {
}
}
@media (max-width: 520px) {
.compat-controls--three,
.settings-group > .compat-probe-grid {
grid-template-columns: 1fr;
}
.compat-event-log li {
grid-template-columns: 52px minmax(0, 1fr);
}
.compat-event-log li > small {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View File

@@ -1,37 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NativeDismissGesture, useNativeRouter } from '@native-vue-router/core'
import AppAvatar from '../components/AppAvatar.vue'
import { useDemoStore } from '../data'
import { computed, ref } from "vue";
import { NativeSheet, useNativeRouter } from "@native-vue-router/core";
import AppAvatar from "../components/AppAvatar.vue";
import { useDemoStore } from "../data";
const native = useNativeRouter()
const store = useDemoStore()
const query = ref('')
const native = useNativeRouter();
const store = useDemoStore();
const query = ref("");
const sheetBreakpoint = ref(0.62);
const snapEnabled = ref(true);
const sheetBreakpoints = computed(() =>
snapEnabled.value ? [0.38, 0.62, 1] : [],
);
async function choose(id: string) {
await native.cancelInteractive()
await native.replace(`/chat/${id}`, { presentation: 'push' })
await native.cancelInteractive();
await native.replace(`/chat/${id}`, { presentation: "push" });
}
</script>
<template>
<NativeDismissGesture as="main" class="screen sheet-screen">
<div class="sheet-handle" aria-hidden="true" />
<header class="sheet-header">
<button type="button" @click="native.dismiss()">Cancel</button>
<h1>New message</h1>
<span />
</header>
<label class="search-field compose-search">
<span>To:</span>
<input v-model="query" autofocus placeholder="Search people" />
</label>
<div class="conversation-list">
<button v-for="person in store.people.value.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))" :key="person.id" class="conversation-row" @click="choose(person.id)">
<AppAvatar :person="person" />
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
<span class="chevron"></span>
</button>
</div>
</NativeDismissGesture>
<NativeSheet
v-model="sheetBreakpoint"
class="compose-sheet"
aria-label="New message"
:breakpoints="sheetBreakpoints"
:initial-breakpoint="0.62"
>
<main class="compose-sheet-content">
<header class="sheet-header">
<button type="button" @click="native.dismiss()">Cancel</button>
<div>
<h1>New message</h1>
<small data-testid="sheet-size">
{{ snapEnabled ? `${Math.round(sheetBreakpoint * 100)}%` : "Auto" }}
</small>
</div>
<button type="button" @click="snapEnabled = !snapEnabled">
{{ snapEnabled ? "Fit content" : "Use snap points" }}
</button>
</header>
<label class="search-field compose-search">
<span>To:</span>
<input v-model="query" autofocus placeholder="Search people" />
</label>
<div class="conversation-list">
<button
v-for="person in store.people.value.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase()),
)"
:key="person.id"
class="conversation-row"
@click="choose(person.id)"
>
<AppAvatar :person="person" />
<div class="conversation-copy">
<strong>{{ person.name }}</strong>
<p>{{ person.handle }}</p>
</div>
<span class="chevron"></span>
</button>
</div>
</main>
</NativeSheet>
</template>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
import AppHeader from '../components/AppHeader.vue'
import { setStoryEntryBlocked, storyEntryGuard } from '../guard-state'
import { NativeLink, useNativeRouter } from "@native-vue-router/core";
import AppHeader from "../components/AppHeader.vue";
import { setStoryEntryBlocked, storyEntryGuard } from "../guard-state";
const native = useNativeRouter()
const native = useNativeRouter();
function toggleStoryGuard() {
setStoryEntryBlocked(!storyEntryGuard.blockEntry)
setStoryEntryBlocked(!storyEntryGuard.blockEntry);
}
</script>
@@ -24,13 +24,35 @@ function toggleStoryGuard() {
</div>
</section>
<section class="settings-list">
<a href="/profile/runtime-lab" @click.prevent="native.sibling('/profile/runtime-lab')"><span></span><strong>Runtime stress lab</strong><i></i></a>
<button type="button" aria-label="Block Stories re-entry" :aria-pressed="storyEntryGuard.blockEntry" @click="toggleStoryGuard">
<span></span><strong>Block cached Stories re-entry</strong><i data-testid="story-guard-status">{{ storyEntryGuard.blockEntry ? storyEntryGuard.status : 'Off' }}</i>
<a
href="/profile/runtime-lab"
@click.prevent="native.sibling('/profile/runtime-lab')"
><span></span><strong>Runtime stress lab</strong><i></i></a
>
<button
type="button"
aria-label="Block Stories re-entry"
:aria-pressed="storyEntryGuard.blockEntry"
@click="toggleStoryGuard"
>
<span></span><strong>Block cached Stories re-entry</strong
><i data-testid="story-guard-status">{{
storyEntryGuard.blockEntry ? storyEntryGuard.status : "Off"
}}</i>
</button>
<NativeLink to="/settings"
><span></span><strong>Navigation lab</strong><i></i></NativeLink
>
<NativeLink to="/profile/vue-lab/alpha?mode=manual#route-state"
><span>Vue</span><strong>Vue compatibility lab</strong
><i></i></NativeLink
>
<a href="https://github.com" target="_blank" rel="noreferrer"
><span></span><strong>Project source</strong><i></i></a
>
<button type="button">
<span></span><strong>Appearance</strong><i>System</i>
</button>
<NativeLink to="/settings"><span></span><strong>Navigation lab</strong><i></i></NativeLink>
<a href="https://github.com" target="_blank" rel="noreferrer"><span></span><strong>Project source</strong><i></i></a>
<button type="button"><span></span><strong>Appearance</strong><i>System</i></button>
</section>
</main>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useNativeRouter } from "@native-vue-router/core";
import { useRoute } from "vue-router";
import AppHeader from "../components/AppHeader.vue";
import {
compatibilityLab,
recordCompatibilityEvent,
} from "../compatibility-lab";
const route = useRoute();
const native = useNativeRouter();
const unloadStatus = ref("Lab remains cached");
const labLocation = computed(() => ({
name: "vue-compatibility",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
}));
function unloadLab() {
const count = native.unload(labLocation.value);
unloadStatus.value = count
? "Lab view evicted; Back will create a new instance"
: "No matching inactive lab view was mounted";
recordCompatibilityEvent("Away screen", "native.unload", `${count} view(s)`);
}
async function unloadAndReturn() {
unloadLab();
await native.pop();
}
</script>
<template>
<main
class="screen compatibility-screen"
data-testid="vue-compatibility-away"
>
<AppHeader
title="Cached-route checkpoint"
subtitle="The compatibility lab is behind this view"
back
/>
<section class="lab-intro compat-intro">
<span></span>
<div>
<strong>Inspect native cache behavior</strong>
<p>
The previous lab instance is inactive but still mounted until you
explicitly unload it.
</p>
</div>
</section>
<section class="settings-group">
<h2>Inactive route controls</h2>
<div>
<span
><strong>Compatibility lab</strong
><small>{{ unloadStatus }}</small></span
><b>behind</b>
</div>
<p>
Use normal Back to observe native activate/show without Vue remount
hooks. Use Unload and return to observe beforeUnmount/unmounted
followed by a fresh component instance.
</p>
</section>
<div class="compat-controls">
<button type="button" data-testid="compat-unload-lab" @click="unloadLab">
Unload cached lab
</button>
<button
type="button"
data-testid="compat-unload-return"
@click="unloadAndReturn"
>
Unload and return
</button>
</div>
<section class="settings-group compat-event-section">
<div class="compat-event-heading">
<span
><strong>Shared lifecycle journal</strong
><small>Events survive route eviction</small></span
>
</div>
<ol class="compat-event-log" data-testid="compat-away-event-log">
<li v-for="event in compatibilityLab.events" :key="event.id">
<time>{{ event.timestamp }}</time>
<span
><strong>{{ event.source }}</strong
><code>{{ event.hook }}</code></span
>
<small>{{ event.detail }}</small>
</li>
</ol>
</section>
</main>
</template>

View File

@@ -0,0 +1,427 @@
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
provide,
ref,
} from "vue";
import {
onNativeViewActivate,
onNativeViewDeactivate,
onNativeViewEvict,
onNativeViewHide,
onNativeViewShow,
useNativeRouter,
useNativeViewLifecycle,
} from "@native-vue-router/core";
import { useRoute } from "vue-router";
import AppHeader from "../components/AppHeader.vue";
import CompatibilityAsyncProbe from "../components/CompatibilityAsyncProbe.vue";
import CompatibilityCompositionProbe from "../components/CompatibilityCompositionProbe.vue";
import CompatibilityInjectProbe from "../components/CompatibilityInjectProbe.vue";
import CompatibilityKeepAliveProbe from "../components/CompatibilityKeepAliveProbe.vue";
import CompatibilityOptionsProbe from "../components/CompatibilityOptionsProbe.vue";
import {
clearCompatibilityEvents,
compatibilityLab,
compatibilityLabContextKey,
recordCompatibilityEvent,
} from "../compatibility-lab";
const route = useRoute();
const native = useNativeRouter();
const nativeLifecycle = useNativeViewLifecycle();
const instanceId = Math.random().toString(36).slice(2, 7);
const revision = ref(0);
const probesMounted = ref(true);
const keepAliveVariant = ref<"A" | "B">("A");
const transitionVisible = ref(true);
const teleportOpen = ref(false);
const suspenseRequest = ref(1);
const routeLabel = computed(() => route.fullPath);
const nativeVisible = nativeLifecycle.isVisible;
const nativeActive = nativeLifecycle.isActive;
const eventCount = computed(() => compatibilityLab.events.length);
provide(compatibilityLabContextKey, {
source: `Vue compatibility lab ${instanceId}`,
routeLabel,
});
const recordPageEvent = (hook: string, detail?: string) =>
recordCompatibilityEvent(`Lab page ${instanceId}`, hook, detail);
onMounted(() => recordPageEvent("onMounted", route.fullPath));
onBeforeUnmount(() => recordPageEvent("onBeforeUnmount"));
onUnmounted(() => recordPageEvent("onUnmounted"));
onNativeViewActivate(() => recordPageEvent("native activate"));
onNativeViewDeactivate(() => recordPageEvent("native deactivate"));
onNativeViewShow(() => recordPageEvent("native show"));
onNativeViewHide(() => recordPageEvent("native hide"));
onNativeViewEvict((reason) => recordPageEvent("native evict", String(reason)));
function updateProbes() {
revision.value += 1;
recordPageEvent("revision changed", String(revision.value));
}
function toggleProbeMount() {
probesMounted.value = !probesMounted.value;
recordPageEvent(probesMounted.value ? "probes inserted" : "probes removed");
}
function switchKeptInstance() {
keepAliveVariant.value = keepAliveVariant.value === "A" ? "B" : "A";
}
function recordTransition(hook: string) {
recordCompatibilityEvent("Transition", hook);
}
function reloadSuspense() {
suspenseRequest.value += 1;
}
function pushAlternateRoute() {
const sample = route.params.sample === "alpha" ? "beta" : "alpha";
void native.push({
name: "vue-compatibility",
params: { sample },
query: { mode: "parameter", revision: revision.value },
hash: "#route-state",
});
}
function replaceQueryAndHash() {
void native.replace(
{
name: "vue-compatibility",
params: { sample: route.params.sample },
query: { mode: "replaced", tick: Date.now().toString().slice(-5) },
hash: "#event-log",
},
{ presentation: "fade" },
);
}
function openAwayRoute() {
void native.push({
name: "vue-compatibility-away",
params: { sample: route.params.sample },
query: route.query,
hash: route.hash,
});
}
</script>
<template>
<main
class="screen compatibility-screen"
data-testid="vue-compatibility-view"
:data-instance-id="instanceId"
>
<AppHeader
title="Vue compatibility"
subtitle="Native component laboratory"
back
>
<span class="compat-header-state" :class="{ active: nativeActive }">{{
nativeActive ? "active" : nativeVisible ? "transitioning" : "cached"
}}</span>
</AppHeader>
<section class="lab-intro compat-intro">
<span>Vue</span>
<div>
<strong>Exercise real framework behavior</strong>
<p>
Every control below runs inside a routed, cached NativeRouterView
entry.
</p>
</div>
</section>
<section
id="route-state"
class="settings-group compat-route-state"
data-testid="compat-route-state"
>
<h2>Scoped route state</h2>
<div>
<span
><strong>Full path</strong
><small>useRoute() and Options API $route</small></span
><code data-testid="compat-full-path">{{ route.fullPath }}</code>
</div>
<div>
<span
><strong>Named route</strong
><small>Matched record identity</small></span
><code>{{ String(route.name) }}</code>
</div>
<div>
<span><strong>Param</strong><small>route.params.sample</small></span
><code data-testid="compat-param">{{ route.params.sample }}</code>
</div>
<div>
<span><strong>Query</strong><small>route.query</small></span
><code data-testid="compat-query">{{
JSON.stringify(route.query)
}}</code>
</div>
<div>
<span><strong>Hash</strong><small>route.hash</small></span
><code data-testid="compat-hash">{{ route.hash || "(empty)" }}</code>
</div>
<div>
<span><strong>Matched</strong><small>route.matched</small></span
><code>{{
route.matched.map((record) => String(record.name)).join(" → ")
}}</code>
</div>
</section>
<div class="compat-controls compat-controls--three">
<button
type="button"
data-testid="compat-change-param"
@click="pushAlternateRoute"
>
Push alternate param
</button>
<button
type="button"
data-testid="compat-change-query"
@click="replaceQueryAndHash"
>
Replace query + hash
</button>
<button
type="button"
data-testid="compat-open-away"
@click="openAwayRoute"
>
Cache this view
</button>
</div>
<section class="settings-group">
<h2>provide() / inject()</h2>
<CompatibilityInjectProbe location="route tree" />
<p>
The same probe is rendered inside the Teleport below to verify
logical-tree injection and scoped routing.
</p>
</section>
<section class="settings-group">
<h2>Options and Composition lifecycle</h2>
<div>
<span
><strong>Shared revision</strong
><small>Changing it triggers beforeUpdate / updated</small></span
><b data-testid="compat-revision">{{ revision }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-update-probes"
@click="updateProbes"
>
Update props
</button>
<button
type="button"
data-testid="compat-toggle-probes"
@click="toggleProbeMount"
>
{{ probesMounted ? "Unmount probes" : "Mount probes" }}
</button>
</div>
<div v-if="probesMounted" class="compat-probe-grid">
<CompatibilityOptionsProbe
:instance-name="`Options ${instanceId}`"
:revision="revision"
/>
<CompatibilityCompositionProbe
:instance-name="`Composition ${instanceId}`"
:revision="revision"
/>
</div>
</section>
<section class="settings-group">
<h2>KeepAlive</h2>
<div>
<span
><strong>Current cached child</strong
><small>Switch away and back after incrementing</small></span
><b>{{ keepAliveVariant }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-switch-keepalive"
@click="switchKeptInstance"
>
Switch to {{ keepAliveVariant === "A" ? "B" : "A" }}
</button>
</div>
<KeepAlive>
<CompatibilityKeepAliveProbe
:key="keepAliveVariant"
:name="keepAliveVariant"
/>
</KeepAlive>
</section>
<section class="settings-group">
<h2>Transition</h2>
<div>
<span
><strong>CSS transition target</strong
><small>Hooks are recorded in the event journal</small></span
><b>{{ transitionVisible ? "shown" : "removed" }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-toggle-transition"
@click="transitionVisible = !transitionVisible"
>
Toggle transition
</button>
</div>
<Transition
name="compat-fade"
@before-enter="recordTransition('before-enter')"
@after-enter="recordTransition('after-enter')"
@before-leave="recordTransition('before-leave')"
@after-leave="recordTransition('after-leave')"
>
<article
v-if="transitionVisible"
class="compat-transition-card"
data-testid="compat-transition-card"
>
Transition child is mounted
</article>
</Transition>
</section>
<section class="settings-group">
<h2>Teleport</h2>
<div>
<span
><strong>Body-level overlay</strong
><small
>Automatically hidden when this native view is inactive</small
></span
><b>{{ teleportOpen ? "armed" : "closed" }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-open-teleport"
@click="teleportOpen = true"
>
Open teleported overlay
</button>
</div>
</section>
<Teleport to="body">
<Transition name="compat-fade">
<div
v-if="teleportOpen && nativeVisible"
class="compat-teleport"
data-testid="compat-teleport-overlay"
@click.self="teleportOpen = false"
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="compat-teleport-title"
>
<button
type="button"
aria-label="Close teleported overlay"
@click="teleportOpen = false"
>
×
</button>
<h2 id="compat-teleport-title">Teleported route content</h2>
<CompatibilityInjectProbe location="teleport" />
</section>
</div>
</Transition>
</Teleport>
<section class="settings-group">
<h2>Suspense</h2>
<div>
<span
><strong>Async setup request</strong
><small>Fallback remains for 750 ms</small></span
><b>#{{ suspenseRequest }}</b>
</div>
<div class="compat-inline-actions">
<button
type="button"
data-testid="compat-reload-suspense"
@click="reloadSuspense"
>
Reload async child
</button>
</div>
<Suspense
:key="suspenseRequest"
:timeout="0"
@pending="recordCompatibilityEvent('Suspense', 'pending')"
@fallback="recordCompatibilityEvent('Suspense', 'fallback')"
@resolve="recordCompatibilityEvent('Suspense', 'resolve')"
>
<CompatibilityAsyncProbe :request-id="suspenseRequest" />
<template #fallback>
<article
class="compat-probe compat-probe--loading"
data-testid="compat-suspense-fallback"
>
<span class="lab-spinner" />
<div>
<strong>Suspense fallback</strong>
<p>Waiting for async setup</p>
</div>
</article>
</template>
</Suspense>
</section>
<section id="event-log" class="settings-group compat-event-section">
<div class="compat-event-heading">
<span
><strong>Lifecycle event journal</strong
><small>{{ eventCount }} retained events · newest first</small></span
>
<button
type="button"
data-testid="compat-clear-events"
@click="clearCompatibilityEvents"
>
Clear
</button>
</div>
<ol class="compat-event-log" data-testid="compat-event-log">
<li v-for="event in compatibilityLab.events" :key="event.id">
<time>{{ event.timestamp }}</time>
<span
><strong>{{ event.source }}</strong
><code>{{ event.hook }}</code></span
>
<small>{{ event.detail }}</small>
</li>
</ol>
</section>
</main>
</template>